Commit Graph
31 Commits
Author SHA1 Message Date
kevinveenbirkenbachandClaude Opus 5 ffe23287d0 fix(web-app-docs): size the sphinx concurrency from the container limit
jobs and parallel were derived from the host CPU count with no memory term,
so a 4g container could be asked to run two builds of two sphinx workers
each. One sphinx process reached 2.39 GB anon-rss while still growing, and
the kernel killed two of them ("Memory cgroup out of memory: Killed process
... (sphinx-build)", cgroup usage 4194304kB of limit 4194304kB). Every build
died there, so every page kept answering 202 until the spec gave up.

mem_limit now follows the host, and both knobs follow mem_limit: parallel
takes the builds the limit can hold, then jobs takes the workers left once
every concurrent build has reserved its overhead. Deriving jobs first let it
claim all the memory and pinned parallel at one; reserving the overhead only
once overcommitted an 8-core host by 683 MB.

The two inputs are declared beside the limit they are measured against.
Neither is a ceiling: no docs build has ever completed in CI, so
sphinx_process_mb is a lower bound that the first green build can correct.

mem_limit calls lookup("resource", ...) directly rather than through a
group_var, the way svc-opt-swapfile does: a meta file renders with a flat
variable dict, which cannot expand a group_var that itself holds a lookup
and would have left the limit at 0.

The generator env also lacked the PYTHONUNBUFFERED that _sphinx_env sets,
so a generator killed under that memory pressure lost whatever its stdout
buffer still held. That is why the integrations failure logged its argv and
nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 18:09:10 +02:00
kevinveenbirkenbachandClaude Opus 5 6bd1279e5e fix(web-app-docs): warm the languages, and let a failed build say so
Run 37553816857 failed 59 jobs, and web-app-docs is in five of every six
that named a role. The spec waits 88 minutes for a German site that never
arrives:

    tests/test-languages.js:25:7 › the deployed working tree is published in de
    Expected the de site of the deployed working tree to be built
    Expected: 200   Received: 202

The container log of any job that deployed the role carries the whole chain:

    "GET /deployed/\1/ HTTP/1.1" 202
    build latest failed: Command '[... 'infinito_docs.generators.integrations' ...]'
      returned non-zero exit status 1.
    "GET /deployed/de/ HTTP/1.1" 202     (every 31s, for 88 minutes)

Four defects, in the order they fire.

The preload asked for a path named `\1/`. The replacement reached re.sub with
its backslash still escaped, so the backreference stayed literal and no
language was ever warmed; the first build then started when a visitor polled.
Reproduced through Templar rather than argued: the old expression renders
['', '\1/'] and the new one ['de/', '']. Plain Jinja2 with Ansible's filters
renders the old one correctly, which is why the first measurement of this
cleared code that the access log had already convicted. The replacement
appends with '$' and carries no backreference to escape.

`latest` then failed to build at all. builder.py puts the documented checkout
first on PYTHONPATH, so the image's generator meets the utils of the version
it is documenting, and a05071fe78 dropped the get_ prefix from entity_name.
A builder that documents every version has to tolerate every version's API,
so the import falls back.

A failed build kept answering 202 and re-queueing itself, so every client
waited its whole timeout and a doomed build restarted on each poll. This is
what turned a one-minute failure into 88-minute waits and drove one job into
the six-hour ceiling, where it was killed with 137. The server now answers
500, through a queue accessor that reuses the comparison `request` already
makes: a failure at an older ref still retries. For a translation the parent
version counts too, because here /deployed/de/ polled while `latest` was the
build that had failed.

The failure line now carries the captured log tail. The detail was never
lost -- _run merges stderr into the build state -- but it was absent from the
container log, which is what a rescue bundle holds, so the traceback had to be
reproduced locally to be read at all.

Nothing here is deployed. The 500 is a live change for three consumers that
accept 202 -- the preload task, test-front-page and test-on-demand-build --
but it is reachable only when a version is unservable AND its build failed at
the ref it would build now, so a healthy deployment sees what it saw before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-07 13:30:18 +02:00
kevinveenbirkenbachandClaude Opus 5 47467f6cce style: settle the formatter and ruff rewrites of this change set
make autoformat reflowed a resource_filter call, a generator expression and five test files it touched along the way. The new architecture test also carried the two findings the first gate run stopped on: three RUF012, where a TestCase class attribute needs ClassVar, and two PERF401, where an append loop becomes list.extend over a generator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-06 00:23:15 +02:00
kevinveenbirkenbachandClaude Opus 5 04d746e339 feat(web-app-docs): reuse one generated tree per ref and build several languages at once
A translated site repeated the whole checkout and generation of its version, and the builder ran one marker at a time. The prepared tree is now cached per (ref, tooling digest), hardlinked into each build's scratch and sealed read-only, and a bounded thread pool claims the markers a free slot can build now - a language only once its version is current, so a stale one is no longer claimed and bounced in a spin.

services.docs.build.jobs and services.docs.build.parallel replace build_jobs and default to half the host's CPUs, parallel capped at 2 so the two knobs cannot multiply past mem_limit. DOCS_I18N_PRELOAD_LANGUAGES replaces DOCS_I18N_LANGUAGE, defaults to the host language and warms [en, de] in both matrix variants, where docs_sites is backed up under swarm so the DR drill restores the built sites instead of rebuilding them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 19:58:17 +02:00
kevinveenbirkenbachandClaude Opus 5 a05071fe78 refactor(roles): let a role declare its entity name and drop the get_ prefix
The entity name keys a role's instance directory, its compose project and its container name, and was derived only by stripping the longest matching category prefix. Two roles under different categories whose names share a suffix therefore collapse onto one entity and overwrite each other's rendered stack, which no provides: can repair.

A literal entity_name in vars/main.yml now wins over the derivation. web-app-seaweedfs-console takes its shorter name back and declares the entity seaweedfs-console, which frees the seaweedfs key in its own services map for the engine consumer and removes the provides:, covers: and alias entries that stood in for it. A new lint pairs the declaration with the primary key it has to match, because a drift between them would drop the role from the provider set in silence.

Compared across all 288 roles, exactly one entity changes, and it restores what main already keeps on disk, so no instance directory moves.

The function and the filter lose their get_ prefix. Two lints read that prefix as the discriminator between the variable and the filter that fills it; both discriminate on the pipe now. Ansible's templar resolves a filter and a same-named variable independently, verified against ansible-core 2.20.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 19:40:53 +02:00
kevinveenbirkenbachandClaude Opus 5 3200995e95 fix(web-app-docs): keep the translated build's scratch path out of PYTHONPATH
The scratch directory of a translated build was named
"{version}{QUEUE_SEPARATOR}{code}", and QUEUE_SEPARATOR is ":". _generate then
exports PYTHONPATH as os.pathsep.join([src, package_dir.parent]), and
os.pathsep on POSIX is also ":", so a path like /scratch/deployed:de/src split
into /scratch/deployed and de/src and the generators could not import the
checkout.

The untranslated build was never affected: build() uses `self.scratch /
version` with no separator in it. Only the language path carried the colon,
which is why the German site was the one that failed.

QUEUE_SEPARATOR stays as it is. It still separates version from language in
the queue marker, which build_queue partitions on; the directory name was the
only place where that character had to be something else.

The language spec also stops retrying and stops recording: the test budgets
90 minutes for two Sphinx builds, so a retry costs three hours of runner for
no new information and a trace over that span is an artefact nobody opens. It
now runs before the on-demand build test rather than after, so the long build
is not queued behind the test that drives the same machinery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 04:39:48 +02:00
kevinveenbirkenbachandClaude Opus 5 bed4b3c84f refactor(docs): derive the role integration pages instead of freezing them
cli/build/docs/integration_matrix was 1189 lines a human ran through
'make integration-matrix', and its output sat frozen as a 125-line grid in
requirement 027. Its ENTITIES and EDGES lists were hand-maintained, so the
grid could only drift: it covered 84 entities while the tree holds 98 roles,
named two roles that no longer exist, and omitted sixteen current ones -
api, checkmk, docs, hermes, homeassistant, jellyfin, meta, mirror, openbao,
openclaw, penpot, pihole, s1, seaweedfs, seaweedfs-console and semaphore.

infinito_docs/generators/integrations.py replaces it and joins the nine
sibling generators in that package, so the overview, services and addons
pages are written into generated/integrations/ on every docs build rather
than when someone remembers the target. index.rst takes the overview and the
new docs/architecture/integrations.rst references the other two.
Requirement 027 becomes prose. The make target and its i8inma alias are
removed as a pair, which keeps the alias lints satisfied.

Coverage is not strictly larger. The removed EDGES list carried a curated
upstream project URL per integration, 96 of them, and the new edges have no
such field; only 48 of 159 addon declarations hold update.upstream_id or
config.repository, so about a third is re-derivable and the rest existed only
in that list. Its fourth field is no real loss - 57 entries read 'check', so
it was a near-constant rather than an axis. In exchange the old module had
zero tests, not one file under tests/ referenced it, and the generator ships
310 lines including generate().

Residuals: the HTML build runs without -W, but i18n.py:57 runs the gettext
builder with -W and check=True, and that build has never seen
integrations.rst - no catalog mentions generated/integrations. And
docs/requirements/036:72 still carries a checked criterion asserting a table
in 027 that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-02 18:22:04 +02:00
kevinveenbirkenbachandClaude Opus 5 0009c12755 fix(web-app-docs): build a translated site from the same sources as its catalog
build() runs eleven generator subprocesses before Sphinx; build_language() went straight to sphinx-build. The translated site therefore rendered without generated/ - no role pages, no overview, no regenerated READMEs - while the .pot extractor does call the generators (i18n.py:47). The catalog described a superset of what the translation could ever show. The loop moves into _generate() and both builds call it. Extracting it also surfaced a division by zero on an empty generator list, now an early return.

build_language() collapsed two different reasons into one bail and treated them alike: drop the marker, request the base. Dropping is right when the code is not translated. It is wrong when the base is merely stale, because the base build short-circuits and never reaches the tail that re-queues the languages, so the translation was lost for good and /deployed/de/ would answer 202 forever. The two reasons are now separate and the stale case re-queues the translation alongside its base.

fetch() re-requests every 900 s and _current() can never be true for an unservable site, so a failed build rebuilt itself forever and starved the background lane. _failed now records the ref it failed on, and request() refuses a BACKGROUND entry for a marker that failed at the ref it would build now. A waiting visitor still gets a retry; only work nobody waits on is throttled. The guard needs a non-empty ref so an empty snapshot ref cannot lock everything out, and _ready replaces the whole state dict, so a later success clears it.

Verified: 158 passed over the roles entire docs unit suite in the container. The extraction is behaviour-identical on the build() path - same PYTHONPATH, phase, progress formula and run call - so the measured delta there is zero. Not verified: build_language has never executed the generator phase anywhere yet, which is the one real risk this carries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 13:17:29 +02:00
kevinveenbirkenbachandClaude Opus 5 ff89a02067 fix(web-app-docs): take the builds nobody waits for off the awaited path
On a cold container the docs Playwright suite triggers four serial Sphinx builds and waits for the last one. test-languages.js polls /deployed/de/ for 200 and got 202 for 55.6 minutes in job 110003713825 of run 36745666606, after which the suite budget expired and five further specs never ran. The suite has never passed on a first attempt; it used to go green only on the harness retry, which 7127c6b96b removed for budget timeouts, so a structural overrun became a red job.

Measured cost per build, from run 36558500327 where the whole queue drained on a kept stack: container up 12:15:52, the spec green at 13:21:41 answering on its first poll, next container start only at 13:30:45. Four builds in 65 minutes 49 seconds, so at most 16.5 minutes each - not the sixty to ninety minutes 40688e4ded estimated.

Three of the four builds were none of the test's business. fetch() queued latest in the foreground although nothing waits on it at startup, and _refresh_stale_sites did the same for every servable version; both now use the background lane, which is what its own docstring describes. test-front-page.js reached GET /latest/, and reaching an unbuilt version queues its build - it now asserts the redirect from the Location header without following it, and checks the same page shape against /deployed/, which is built anyway. The release tag that test-on-demand-build.js clicks stays, and now runs behind the awaited work instead of in front of it.

The translation could only be queued after its base finished, because the 202 branch returns before _translation is reachable, so it always sorted behind whatever the sibling specs had queued meanwhile. The 202 branch now carries the language segment along, admitted by translatable(), which answers from the snapshot catalog on disk before any build; build_language still re-checks the written index.

Moving a version into the background lane exposed that status() read only the foreground one, so a waiting version would have been reported as missing rather than queued to the versions overview and to /api/versions. is_queued() now covers both lanes, mirroring _dequeue, which always removed from both. Two of the five unit failures this change first produced disappeared on that fix alone, which is what shows they pinned real behaviour; the three that pinned the lane itself were rewritten to assert the behaviour instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 11:32:08 +02:00
kevinveenbirkenbachandClaude Opus 5 69f2233530 fix(web-app-docs): ship the two repository files the generators read
`/snapshot` is a tar of `DOCS_SNAPSHOT_PATHS` and the list carried 32 entries,
neither of them `Makefile` or `aliases`. Both are tracked at the repository
root and `commands.py:80-90` hands them to two generators,
`make_targets --makefile {src}/Makefile` and `aliases --aliases-file
{src}/aliases`. `generators/make_targets.py:45` reads with no existence guard,
so the build dies with FileNotFoundError before sphinx is reached.

It dies without saying so anywhere. `_failed` wrote the exception into
`states/deployed.json` and printed nothing, and `library.status` iterates
`[LATEST, *tags]`, so `deployed` never appears in `/api/versions`. No log, no
API and no test carried the failure. `/deployed/de/` therefore answered 202 for
as long as the container lived and the Playwright suite polled a dead build for
its entire budget - eight jobs of run 36651124458, three hours each.

The failing test's trace settles which build it was: the 202 body carries
`<title>Building deployed</title>`, not `deployed/de`. The English `deployed`
version is the one that never lands, so the German build is never even queued.
That also gives the discriminator: `latest` and the release tags build from a
`git archive` of the mirror, where both files are present, and they reach
`ready` in the same run.

Three parts. The list gains the two entries, in the file's ASCII order - that
is the fix. `_failed` also prints to stderr, because without it the next such
gap is just as invisible; the moodle job's docs container log was 484 access
log lines and nothing else. And a test derives the requirement instead of
restating it: it calls `generate_commands()` and requires every first path
segment under `src` to be in the snapshot, `generated` excepted, since that one
the build creates. Adding two entries by hand would have broken the same way on
the next generator.

Main is unaffected: the role does not exist there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 15:20:40 +02:00
kevinveenbirkenbachandClaude Opus 5 40688e4ded fix(web-app-docs): stop a poll from sending the awaited build to the back
`request()` ended in `touch(exist_ok=True)`, and on an existing file that
refreshes the mtime. `next_queued()` sorts by mtime and serves the oldest
marker, so re-requesting a build that is already queued moved it behind
everything else. The server calls `request()` on every visit, and a visitor
waiting for a page polls every thirty seconds, so waiting for a build was
what prevented it:

    queued de, then a tag : ['deployed:de', 'v14.2.0'] -> builds deployed:de
    after one poll on de  : ['v14.2.0', 'deployed:de'] -> builds v14.2.0

One poll is enough. In the docs suite the spec before it deliberately clicks
an unbuilt release tag, so another hour-long build is always sitting there to
be overtaken by.

Observed in run 36596159856: `/deployed/de/` answered 202 and never 200 for
two hours and four minutes in one deployment and two hours fifty-seven in
another, from those jobs' own rescue artifacts, while the suite budget is
fifty-nine minutes on clearnet and eighty-nine on tor.

This removes the starvation, not the arithmetic: a full Sphinx build costs
sixty to ninety minutes here and the suite needs two of them in sequence,
English then German. Whether one round now fits is for a run to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 01:46:03 +02:00
kevinveenbirkenbachandClaude Opus 5 e47071a9c6 feat(docs): derive the make, alias and CLI references from their sources
make.md names 'make help' as the authoritative target list and the site never carried one, so a reader had to run the repository to see what it offers. The same held for the i8 aliases and for the infinito command tree.

Three generators join the ones the build already runs for the YAML index and the role glossary. make_targets reads the comment block above each target, the same block make help prints, and turns 'Param x:' into a field. aliases reads the trailing comment, falling back to the target an alias wraps, because a plain make wrapper carries no comment by repository rule; an unparseable line raises instead of vanishing from the page. cli_commands asks cli.core.discovery, the rule the dispatcher itself applies, so the page cannot promise a command the CLI rejects.

cli_commands parses each __main__.py with ast rather than importing it: rendering a page would otherwise run 65 modules' import side effects, and a broken one degrades to 'Undocumented.' instead of losing the page.

literal() escapes the inline markup starters in prose taken from those files. 3b81b4de2e fixed this class once already, where a role description reading (*.parent) opened an emphasis that never closed; a Makefile comment naming two globs does the same. Measured: the unescaped form reports three docutils warnings, the escaped form none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-29 13:39:53 +02:00
kevinveenbirkenbachandClaude Opus 5 77cb6251a1 fix(web-app-docs): stop serving a deployed build the run did not stage
The site answers 202 while it builds a version, and 200 once a build
exists. Whether that build matches what the version currently names was
never asked at the serving gate: servable() only checks that an index.html
is on disk. So the first request after a new snapshot got the previous
build with 200, and the rebuild it triggered finished into a directory
nobody was waiting for.

For a release tag that cannot happen, and for latest it is a reasonable
trade, since a visitor would rather read the previous page than a spinner.
For deployed it is wrong by definition: the name means the working tree
this run staged, so an earlier snapshot is not a stale answer but a
different question's answer. The post-deploy test that reads the published
page therefore validated the round before it, and reported a role broken
whose page had already been fixed.

The comparison already existed. _current() weighs built_ref against
_wanted_ref, which resolves deployed to the snapshot digest, and the
builder consults it to decide whether to rebuild. Only the server did not.
outdated() exposes it for deployed alone and the serving branch now treats
a mismatch as not yet servable, so the poll waits for the build it asked
for.

Measured on the stack while the previous round was live: the served page
was written 16:44:41 from the older snapshot, its own source carried the
newer content, and the correct build landed at 17:10:56, twenty-six
minutes after the test had already read the stale one and failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 22:54:30 +02:00
kevinveenbirkenbachandClaude Opus 5 2ba37b55b2 style: settle the formatter and linter rewrites of this change set
ruff format reflows long calls and normalises quotes, isort reorders the
imports of the documentation lint package, SIM117 folds nested `with`
statements into one parenthesised block, TC003 moves typing-only imports
under `if TYPE_CHECKING`, the JavaScript linter replaces a `var`, and the
YAML formatter rewraps five role files. None of it changes behaviour.

One entry does more than that: the formatter had reflowed two calls in
test_builder.py so their `# nocheck: cache-read` markers landed on the
closing parenthesis, where they suppress nothing and the cache-read lint
would have reported two real violations. Both calls are hoisted into a
variable so the marker sits on the reading line again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 04:53:30 +02:00
kevinveenbirkenbachandClaude Opus 5 c39f3ec9ee feat(web-app-docs): replay the published instructions in its CLI test
The replay of a role's "### Production" block moves out of the CI guide
scripts and into the documentation role itself. The block is read off the
rendered page over HTTP, so what runs is what a reader copies, and the role
that publishes the page is also the role that proves it.

scripts/github/guide/{one,compose_deploy,host_deploy}.sh and
cli/build/docs/readme/production.py are gone with the workflow step that
called them. The instructions marker now only adds web-app-docs to the row's
apps; test-e2e-cli discovers the role through its new templates/test.env.j2
and runs files/test/test.sh.

The block runs in a throwaway machine, never on the stack host. compose.yml
starts one service built from files/test/Dockerfile, whose base is
INFINITO_PARENT_IMAGE out of the mounted checkout's .env, so the machine is
the stack host's own distribution and the replay exercises the installation
instead of a prebuilt image. files/test/init.sh adds an init when the base
carries none and keeps the full -Syu that ef0f03ea98 established for arch.
scripts/tests/workspace/base/01_install.sh then prepares the machine before
the block runs.

Nothing is skipped any more. The previous host-install branch exited 0 and
passed silently over 65 of the 178 invokable roles; a block without a
"docker run" is now replayed against systemd in that same machine, with the
git clone dropped in favour of the mounted tree under test.

The target role comes from the new guide_role lookup, which reads
list_invokable_app_ids() rather than matching role-name prefixes, and falls
back to the asking role when the round deploys nothing else. deployment.py
grows running_apps() so both lookups share one definition of what the round
deploys. required_by_coverage.role_is_invokable drops its own walk over
categories.yml and delegates to the same SPOT; measured equal across all 280
roles.

Carried in the same index, because the refactor pass ran over this change
set: library.py splits into sites.py and build_queue.py mixins, builder.py
hands translated_languages to catalogs.py and folds three repeated log-tail
and state writes, and placeholders.py becomes a facade over spans.py,
repairs.py and damage.py. No test changed for those splits, and the two
ref-resolution dicts builder.py carried are replaced by _wanted_ref, which
also stops hashing the deployed tree on every tag build.

Verified: 7132 unit and lint tests pass over the whole tree, the guide_role
lookup renders through a real Templar, the base image resolves from .env,
and compose config validates the service. The replay itself has never been
executed; that needs a deploy of web-app-docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 04:39:48 +02:00
kevinveenbirkenbachandClaude Opus 5 aceb5295ef feat(docs): let the documentation build generate what make docs generated
Cosmos, Quick Setup and Credits follow from meta/services.yml, categories.yml
and galaxy_info.author, yet 279 role READMEs carried a copy that went stale
whenever the metadata moved and nobody re-ran make docs. The documentation
build now runs the generators itself, against its own scratch checkout, and
the committed READMEs keep only the prose a person writes.

utils.cache.PROJECT_ROOT derives from the module's location, so putting the
checkout first on PYTHONPATH makes the generator operate on the scratch tree.
i18n.py and builder.py add it; the one remaining coupling, a relative_to over
PROJECT_ROOT in a log line, is gone.

CI extracted the "### Production" block from the committed README with awk.
Stripping the section made guide_deployable() return "" for every role, which
selects no deploy row and turns nothing red: a fail-open regression. It now
asks _is_role_invokable, which matches the old condition exactly (178 roles
either way, both difference sets empty), and cli/build/docs/readme/production.py
renders the block for the two guide scripts. All 178 renders are byte-identical
to what HEAD served, and both scripts' extraction pipelines produce identical
output for a web app, a service and a desktop role.

test_readme.py inverts: the three sections are now a hard failure in a
committed README. readme_md.md documents what each is derived from.

Removed: the docs, cosmos, readme-generate, readme-index and readme-check
targets, the five aliases pointing at them, and docs from make quality.

Verified by running generate_commands() in series against a copy of the
checkout: every command exits 0, no README is left without Cosmos or Credits,
and exactly the 178 invokable roles get a Quick Setup. The ansible_roles step
needs pandoc, which only the docs image carries, so that one link of the chain
is unexercised here; it is unchanged by this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 01:09:22 +02:00
kevinveenbirkenbachandClaude Opus 5 d206f2593e feat(web-app-docs): render mermaid fences as diagrams
Every role README carries a ```mermaid flowchart and the documentation
showed it as source. A post-transform now replaces those literal blocks with
<pre class="mermaid"> in html builds, which the runtime picks up; every other
builder keeps the fence as it was.

sphinxcontrib-mermaid injects an inline init script, which this deployment's
CSP refuses, so the runtime and its initialisation are two linked files
instead: mermaid-init.js calls mermaid.initialize with securityLevel strict
and follows the reader's colour scheme. The diagram source is html-escaped
before it is embedded, or an & or < in a node label would break the page.

The runtime is fetched at image build time through ADD --checksum, so a
changed upstream file fails the build rather than shipping. Version and
digest are not ARG defaults, which test_arg_without_default forbids; they
travel meta/services.yml -> vars/main.yml -> compose.yml.j2 the way
DOCS_IMAGE and DOCS_VERSION already do, and an update block registers the
pin with the npm version watcher instead of exempting it.

Verified by building a page with sphinx: the diagram arrives as
<pre class="mermaid"> with its source escaped, a bash fence stays a
highlighted block, and both scripts are linked. A missing mermaid.min.js does
not fail that build, so a local run without the image stays green.

scripts/lint/mermaid.sh keeps validating diagram syntax through mmdc, which
npm installs unpinned. It therefore checks against a newer mermaid than the
11.4.1 readers get, and a diagram using syntax from in between would pass the
lint and render blank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 01:02:30 +02:00
kevinveenbirkenbachandClaude Opus 5 4bf9dc9577 refactor(web-app-docs): group the untranslated-markup extensions in a package
untranslated_markup.py and untranslated_yaml.py shared a name prefix in one
directory, which test_prefix_grouped_files reads as a folder in disguise. They
are now extensions/untranslated/markup.py and yaml.py, beside the __init__.py
that extensions/local/ already carries for the same reason.

A module named yaml.py inside a package does not shadow PyYAML, because
imports are absolute: all nine extensions conf.py registers still import, and
the docs-role unit suite stays green.

The unit tests move with them, keeping the parents[9] root derivation their
sibling package uses at the same depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 00:57:03 +02:00
kevinveenbirkenbachandClaude Opus 5 85760a60b6 feat(i18n): withhold a message that is nothing but a name
One predicate now answers what no translator may be handed, and both ends of
the pipeline read it: the extraction withholds the message from the catalog,
the client refuses to send it. What the catalogs keep back is exactly what the
client would have declined.

Two shapes qualify. A message with no word is markup. A message that is only a
name is a label: a brand, this software, a credited person, or a bare URL.
Inside a sentence the name stays protected by the span machinery and the
sentence still needs translating.

Three details each come from a finding. The comparison runs against the prose,
because **Mastodon** is not the string Mastodon and prose() already strips the
emphasis. Case is ignored, because a page writes the name the way its sentence
needs it and **mailu** is still Mailu. The address is matched first, on the raw
text, because masking protects the address of mailto:a@b.c but leaves the
scheme behind and its prose reads as a word.

Measured over the docs domain: 22745 messages fall to 22402 with zero
warnings, and 145 of the 377 removed entries carried a corrupted brand that
was being shipped - Keycloak read Schluesselmantel, OpenProject read offenes
Projekt, micro read Mikrowelle.

SOFTWARE_NAME is read from the group_vars SPOT beside SOFTWARE_REPOSITORY. It
lived there already and no python had ever read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:57:06 +02:00
kevinveenbirkenbachandClaude Opus 5 57610ff0e5 feat(docs): keep a word-free message out of the catalogs
A table cell holding only a link has nothing to translate, yet it became a
msgid like any sentence: 2999 of the docs domain's 25742 messages, 11.7
percent. They can never be filled, so they count as pending for good and
skew every completeness figure.

The messages come from sphinx's own extract_messages, which is what the
gettext builder collects. That is not cosmetic: sphinx takes a paragraph's
rawsource, so judging astext() would read 'e2e/' out of [`e2e/`](e2e/),
find a word in it and let the markup through. A test now pins that
difference, because the first version of the test could not see it.

The predicate is has_words, the one the translation client already applies
before it sends a batch, so extraction and translation share one definition
rather than two that drift.

Measured: 25742 messages fall to 22745, no surviving msgid loses its
translation, and 2924 of the 2948 removed entries carried their own source
as their translation.

The import is guarded because the site builds arbitrary refs and one older
than utils/i18n/ cannot supply the predicate; the transform then does
nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:12:22 +02:00
kevinveenbirkenbachandClaude Opus 5 d8caa45420 fix(docs): resolve a directory link to its index before its README
Sphinx prefers nothing: dir/index and dir/README are two independent
documents. The preference lives wherever something maps a folder onto a
page, and this repository had two such places disagreeing.
local.file_headings has always hidden README.md from a directory carrying
an index.rst, while yesterday's resolver saw README only, so a link led to
the page the sidebar hides.

Both now try index.rst, index.md, README.md, README.rst in that order. The
extension is renamed accordingly, and the tuple lives once in the lint
package rather than a third time.

That also repairs two links this branch degraded: roles/ holds an index.rst
and no README, so a README-only rule reported every reference to it as
broken and they were turned into code spans.

Three links stay code spans on purpose. Their text names something the
target is not - a suite, a file - so resolving them would restore a
misleading reference rather than a working one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 20:08:26 +02:00
kevinveenbirkenbachandClaude Opus 5 3b81b4de2e fix(docs): stop the documentation toolchain from emitting warnings
apidoc now excludes roles/ and library/ the way it already excluded tests/.
A role name carries hyphens and is therefore not an importable module path,
so automodule reported 44 invalid signatures and rendered nothing; library/
holds ansible modules whose ansible.module_utils imports exist only inside a
play. Measured: 168 generated pages drop to 151, and the 17 that go consist
of a heading and automodule directives on unresolvable paths.

The role page generator fits every section underline to its title. pandoc
counts one character fewer than docutils where a heading ends in a stray
variation selector left behind by a removed emoji. It also escapes inline
markup in role metadata, because one description reads (*.parent) and opened
an emphasis that never closed.

The index generator underlined a 21-character caption with 19 characters.

conf.py adds the tel scheme, without which the phone number in SECURITY.md
is an unresolvable reference, and the extract copies the image assets into
its conf directory the way the site builder does, without which every run
reports a missing html_static_path and favicon.

Four tests pinned values this changes. The fifth, the extension count in
test_conf, asserted nothing the loop below it does not already prove and
broke on every legitimate addition; it now asserts the list is non-empty, so
that loop cannot run over nothing.

With this, make i18n-extract domain=docs finishes with zero warnings under
the -W gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 19:30:04 +02:00
kevinveenbirkenbachandClaude Opus 5 8a048848a9 fix(docs): keep a role metadata value on its bullet
Almost every role declares galaxy_info.company as a block scalar. The
generator rendered each entry as '- **key**: value', so the second line
landed in column 0 and ended the bullet list it sat in. 280 of the build's
remaining 354 docutils messages came from generated/roles/*.rst, which is
this generator's own output; folding every value onto one line clears 277
of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:48:24 +02:00
kevinveenbirkenbachandClaude Opus 5 7440371f62 feat(docs): fail the gettext extract on a sphinx warning
Sphinx collects every warning, prints it, and only then exits non-zero
since version 8, so -W gives exactly the behaviour asked for without a
grep over the output string. check=True carries the code up and
make i18n-extract ends with Error 1.

Only the docs domain passes through sphinx, so make i18n-extract
domain=core is unaffected.

The gate is red until the remaining build warnings are gone. While it is,
the catalogs of the docs domain cannot be regenerated, because the .pot
is copied after the sphinx call returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:23:55 +02:00
kevinveenbirkenbachandClaude Opus 5 bd15eae9fa fix(docs): let the build resolve yaml pages and directory links
The gettext build emitted 23251 warnings. Three causes carried 22424 of
them, and each is answered with the mechanism sphinx supplies.

conf.py read .yml and .yaml through the reStructuredText parser, where
yaml indentation is a block quote: 16107 warnings, and the pages had no
title either. A parser renders them as the literal block they are, and
marks them orphan because a yaml file is reference material rather than a
chapter, which drops another 3062 toctree warnings.

myst builds a document reference only when the link target is_file(), so
every link to a directory fell through as unresolvable. A post-transform
ahead of myst's resolver points such a link at the directory's README,
but only where that README exists, which leaves the genuinely dangling
ones reported. Together with myst_heading_anchors, which was never
configured, that clears 2921 missing cross-references and 320 python
references.

Measured per step over four full builds: 23251 -> 7130 -> 4068 -> 827.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 17:47:07 +02:00
kevinveenbirkenbachandClaude Opus 5 c6d1e64957 fix(web-app-docs): notice a served version whose site fell behind its ref
fetch() requested latest and run_builder() requested deployed once at startup, so a release tag was built on demand and then never revisited. A tag carrying a languages.json from before the translated-site split stays servable, nothing ever requests it, translates() stays false for every code and /v1.0.0/de/ answers a hard 404 forever.

_refresh_stale_sites() asks for every version that is already servable; request() checks _current() itself, so only a genuinely stale one is queued and an unbuilt tag stays on demand instead of triggering a mass rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 15:13:12 +02:00
kevinveenbirkenbachandClaude Opus 5 edc7b576ae fix(web-app-docs): rebuild a translated site when its version moves
request(..., background=True) skipped a language as soon as translation_servable() found an index.html, which is an existence check with no ref in it. A language was therefore built exactly once: every later commit advanced the English site while every translated site stayed frozen on the commit it was first built from. The eager loop this replaced rebuilt them all, so the freeze arrived with f5eff500c1.

_publish_site already stamps the ref beside each translated site and nothing ever read it. translation_ref() reads it and _translation_current() compares it against _wanted_ref(), which also replaces the eager {LATEST: head, DEPLOYED: snapshot_ref()} dict so an unreadable snapshot no longer kills the builder while building an unrelated version.

Two more from the same review: build_language wrote into the version's state file, so a failing language reported the whole version as failed and leaked its sphinx log into /api/versions; it now uses its own version:code key. And the language index was written after the site was published, so a failure in between left a current site with a stale index, which _current() rejects forever and fetch() would rebuild on every tick; the index is written first.

library.py had grown past the 500-line cap, so the build machinery moves into builder.py as a Builder mixin. An AST diff proves every function, method and constant is byte-identical apart from _write_json -> write_json, and the six build-focused test cases move to test_builder.py with the count unchanged at 16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 14:52:34 +02:00
kevinveenbirkenbachandClaude Opus 5 f5eff500c1 feat(web-app-docs): build a translated site on request instead of all of them inline
build() ran sphinx once per translated language before returning, so a deploy blocked for roughly 30 minutes per language. With 29 translated catalogs German sat eighth and its site appeared after about three and a half hours, which is why the language spec polled /deployed/de/ into a 404 for its whole run. The server already answered an unbuilt version with 202 and a build request; an unbuilt language got a bare 404.

Give languages the same treatment. request() takes a code and writes a version:code marker, build_language() builds exactly one from its own checkout via the extracted _checkout(), and the server answers a translated but unbuilt language with 202 while an untranslated one keeps its 404. No language is dropped: build() now queues every translated one into queue/background/, which next_queued() drains only after the lane a visitor is waiting on, so the requested language overtakes the backlog.

languages.json grows a translated list beside known, and _language_index() reads a pre-split file as known-only so an existing volume keeps naming its languages instead of rendering an empty switcher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 12:30:06 +02:00
kevinveenbirkenbachandClaude Opus 5 6a23926648 feat(docs): build and serve a translated site per language
Every version whose tree holds a locale/<code>/LC_MESSAGES/docs.po with at
least one translation now gets one site per such language next to the
English one, built with `-D language=<code> -D html_copy_source=0` after
the English site is published. Sphinx reads one docs.po per language
(locale_dirs, gettext_compact = "docs"); YAML pages stay untranslated
through an extension that runs before the Locale transform.

The deployed working tree is built as the unlisted version `deployed` from a
snapshot baked into the image, because the git mirror only holds what is
pushed to main. The server answers /<version>/<code>/ and
/api/languages/<version>, a language without a site is a 404 rather than the
English page, and a switcher on every page lists the built languages and
keeps the page path. min_storage grows to 13GB for the translated sites.

The 47 docs.po catalogs hold the 24869 documentation messages; German is
partially machine-translated so far. The raw-docker and primary-domain lints
skip .po files, which carry the Markdown's own commands and hosts as
translation sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:56:06 +02:00
kevinveenbirkenbachandClaude Opus 5 7026565f86 fix(web-app-docs): group the local navigation extensions in their own package
The prefix-grouped-files lint rejects two siblings sharing the local_
prefix. They now live in extensions/local/ and import nav_utils
absolutely, which TID252 requires for a parent-package import.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 19:27:23 +02:00
kevinveenbirkenbachandClaude Opus 5 6eb9a59d14 feat(web-app-docs): merge infinito-sphinx and serve every release on demand
The Sphinx configuration, extensions and generators lived in the separate
infinito-sphinx repository, whose image baked a snapshot of core at build time
from a base image name core no longer publishes. Only core consumed it, and its
generators read core's role layout, so the two repositories drifted without any
CI covering the pair.

The tooling now ships in roles/web-app-docs/files/, and web-app-sphinx is
renamed to web-app-docs. The container is a small service instead of a static
image:

- it keeps a git mirror of the repository in the docs_sites volume;
- /latest/ is the last commit of the default branch, rebuilt whenever a fetch
  finds a new one while the previous build stays online;
- every release tag is selectable in the sidebar switcher and on /versions/,
  and is built the first time someone opens it, behind a progress bar;
- replicas share the volume: the one holding builder.lock fetches and builds,
  the queue and the build states are files beside the sites, so another
  replica resumes the queue when the builder dies.

The theme rewrites every page serially after the build, which took about 75
minutes for the 4544 pages; that pass now runs on the -j workers with the
theme's own function. sphinx-apidoc skips tests/, because
tests/utils/bare_bootstrap_probe.py installs an import blocker when autodoc
imports it and killed the Sphinx workers. The roles overview is generated by a
separate process, so autodoc resolves utils from the version being built, not
from main. The role pages now show galaxy_info.description instead of the
top-level key that never existed.

The Sphinx packages join the dev extra, because the mirrored unit tests of the
tooling import them. min_storage now budgets the built image, the latest site
and a running build's scratch instead of the old image's size. The Playwright
suite is split into front page, versions and on-demand build files; the last
one selects an unbuilt tag in the overview and asserts the build gets queued.

Operators must rename the web-app-sphinx inventory group to web-app-docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:39:54 +02:00