Repository navigation
Expand file tree
/
Copy pathjustfile
More file actions
417 lines (387 loc) · 23.5 KB
/
Copy pathjustfile
File metadata and controls
417 lines (387 loc) · 23.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# Task runner for the agent-kit uv workspace.
#
# `uv sync --package X` mutates the ONE shared workspace `.venv` (there is no
# per-package venv in a uv workspace). Testing sibling packages back to back
# in that same venv means package Y's sync can silently change what package
# X's already-passing tests import the next time they run — a real failure
# mode, not a hypothetical one. `--isolated` sidesteps it entirely: each
# `test-*` recipe below builds its own throwaway venv from just that
# package's `[dependency-groups].test`, so no recipe can see another's state,
# and `test-all` runs them all at once (`[parallel]`, native to just — see
# `just --help`) without needing tox/nox to get the same isolation.
set shell := ["bash", "-uc"]
# Every workspace member: `just recipe-name` <-> `packages/name` path.
# Keep in sync with `[tool.uv.workspace].members` in the root pyproject.toml.
# Run agent-config-kit's tests in an isolated venv.
test-agent-config-kit *args:
uv run --isolated --package agent-config-kit --group test pytest packages/agent-config-kit {{ args }}
# Run ol-agent-kit's (packages/agent-kit) tests in an isolated venv.
test-ol-agent-kit *args:
uv run --isolated --package ol-agent-kit --group test pytest packages/agent-kit {{ args }}
# Run witan-core's tests in an isolated venv.
test-witan-core *args:
uv run --isolated --package witan-core --group test pytest packages/witan-core {{ args }}
# Run witan-council's (mcp/servers/witan) tests in an isolated venv, in
# parallel (pytest-xdist; see the `Run tests` step in witan-tests.yml). Pass
# `-n 0` to run serially, e.g. for `--pdb`, which xdist does not support.
test-witan-council *args:
uv run --isolated --package witan-council --group test pytest mcp/servers/witan -n auto --dist worksteal {{ args }}
# Run witan-code's (mcp/servers/witan-code) tests in an isolated venv.
test-witan-code *args:
uv run --isolated --package witan-code --group test pytest mcp/servers/witan-code {{ args }}
# Run every workspace package's tests, each in its own isolated venv, in
# parallel. No args forwarding here since each dependency can only be called
# once per recipe — run a `test-<package>` recipe directly to pass pytest args
# (`-k`, `-x`, ...) to a single package.
[parallel]
test-all: test-agent-config-kit test-ol-agent-kit test-witan-core test-witan-council test-witan-code
# Alias for `test-all`.
test: test-all
# Fail if the three omnigraph version pins have drifted apart.
#
# omnigraph uses strict single-version storage: a binary refuses a graph
# written by a different on-disk format, in either direction. So the version
# `witan setup` puts on a developer's PATH, the version baked into the MCP
# tier's image, and the version the deployed data tier runs are not three
# independent choices — they are one, spelled three times. Renovate's custom
# manager bumps all three (renovate.json), but it silently covered only the
# first for a full release cycle, and nothing failed until deploy. This is the
# second belt.
#
# Parsed with awk, not `grep -oP`: -P is a GNU extension the BSD grep on macOS
# does not have, and darwin/arm64 is a supported installer platform
# (_OMNIGRAPH_ASSETS). A check a Mac developer cannot run is a check that only
# ever fails in CI.
check-omnigraph-pins:
#!/usr/bin/env bash
set -euo pipefail
installer=$(awk -F'"' '/^_OMNIGRAPH_VERSION = /{print $2; exit}' packages/witan-core/witan_core/omnigraph_install.py)
server=$(awk -F= '/^ARG OMNIGRAPH_VERSION=/{print $2; exit}' docker/omnigraph-server.Dockerfile)
mcp=$(awk -F= '/^ARG OMNIGRAPH_VERSION=/{print $2; exit}' docker/witan.Dockerfile)
# The TAG is checked alongside the version, and for the same reason: it
# decides which upstream build each tier actually downloads, so a partial
# bump ships a client and a server on different binaries just as surely.
# Adding a second pin without extending this check would recreate exactly
# the gap the comment above describes.
installer_tag=$(awk -F'"' '/^_OMNIGRAPH_RELEASE_TAG = /{print $2; exit}' packages/witan-core/witan_core/omnigraph_install.py)
server_tag=$(awk -F= '/^ARG OMNIGRAPH_RELEASE_TAG=/{print $2; exit}' docker/omnigraph-server.Dockerfile)
mcp_tag=$(awk -F= '/^ARG OMNIGRAPH_RELEASE_TAG=/{print $2; exit}' docker/witan.Dockerfile)
# ★ AND THE DIGEST, which is the only one of the three that actually pins a
# BUILD. `edge` is force-updated on every push to upstream main, so equal
# tags prove nothing: each tier can resolve the same tag to a different
# commit and every check above still passes, leaving a load-test result
# impossible to attribute. The linux/x86_64 digest is the one all three
# tiers share, so it is the one compared here.
installer_sha=$(awk '/^_OMNIGRAPH_ASSET_SHA256/{f=1} f && /omnigraph-linux-x86_64/{getline; gsub(/[^0-9a-f]/,""); print; exit}' packages/witan-core/witan_core/omnigraph_install.py)
server_sha=$(awk -F= '/^ARG OMNIGRAPH_SHA256_X86_64=/{print $2; exit}' docker/omnigraph-server.Dockerfile)
mcp_sha=$(awk -F= '/^ARG OMNIGRAPH_SHA256_X86_64=/{print $2; exit}' docker/witan.Dockerfile)
# ★ AND THE STORAGE FORMAT, which the two images now carry as an OCI label
# (edu.mit.ol.omnigraph.internal-schema) so ol-infrastructure can refuse a
# preview whose deploying image reads a different format than the cluster
# serves. A label that drifts from the installer declaration is worse than
# no label: the Pulumi check would compare the stack against a number this
# repo does not believe, and pass.
installer_schema=$(awk '/^_OMNIGRAPH_INTERNAL_SCHEMA = /{print $3; exit}' packages/witan-core/witan_core/omnigraph_install.py)
server_schema=$(awk -F= '/^ARG OMNIGRAPH_INTERNAL_SCHEMA=/{print $2; exit}' docker/omnigraph-server.Dockerfile)
mcp_schema=$(awk -F= '/^ARG OMNIGRAPH_INTERNAL_SCHEMA=/{print $2; exit}' docker/witan.Dockerfile)
# An empty capture means the line moved or was renamed, not that the pins
# agree — three empty strings would otherwise compare equal and pass.
for pair in "installer:$installer" "server:$server" "mcp:$mcp" \
"installer_tag:$installer_tag" "server_tag:$server_tag" "mcp_tag:$mcp_tag" \
"installer_sha:$installer_sha" "server_sha:$server_sha" "mcp_sha:$mcp_sha" \
"installer_schema:$installer_schema" "server_schema:$server_schema" "mcp_schema:$mcp_schema"; do
if [[ -z "${pair#*:}" ]]; then
echo "could not read the omnigraph pin for '${pair%%:*}' — the declaration moved or was renamed" >&2
exit 1
fi
done
if [[ "$installer" != "$server" || "$installer" != "$mcp" ]]; then
echo "omnigraph version pins have drifted:" >&2
echo " packages/witan-core/witan_core/omnigraph_install.py: $installer" >&2
echo " docker/omnigraph-server.Dockerfile: $server" >&2
echo " docker/witan.Dockerfile: $mcp" >&2
exit 1
fi
if [[ "$installer_tag" != "$server_tag" || "$installer_tag" != "$mcp_tag" ]]; then
echo "omnigraph release-tag pins have drifted:" >&2
echo " packages/witan-core/witan_core/omnigraph_install.py: $installer_tag" >&2
echo " docker/omnigraph-server.Dockerfile: $server_tag" >&2
echo " docker/witan.Dockerfile: $mcp_tag" >&2
exit 1
fi
# Agreement across tiers is not enough: Renovate rewrites only the version
# lines, so a bump leaves all three tags (and digests) on the old release,
# and every check above still passes while CI downloads the old binary under
# the new label (agent-kit#342). `edge` is exempt because on a moving tag the
# version and tag legitimately differ.
if [[ "$installer_tag" != "edge" && "$installer_tag" != "v$installer" ]]; then
echo "omnigraph release tag $installer_tag does not match version $installer." >&2
echo "Move _OMNIGRAPH_RELEASE_TAG, both Dockerfiles' OMNIGRAPH_RELEASE_TAG, and" >&2
echo "the asset digests to v$installer together (or use \`edge\` deliberately)." >&2
exit 1
fi
if [[ "$installer_sha" != "$server_sha" || "$installer_sha" != "$mcp_sha" ]]; then
echo "omnigraph linux/x86_64 digest pins have drifted — the tiers would" >&2
echo "install DIFFERENT builds even though the version and tag agree:" >&2
echo " packages/witan-core/witan_core/omnigraph_install.py: $installer_sha" >&2
echo " docker/omnigraph-server.Dockerfile: $server_sha" >&2
echo " docker/witan.Dockerfile: $mcp_sha" >&2
exit 1
fi
# The global ARG agreeing is not enough to produce the label. Docker scopes
# a global ARG out of every stage, so a runtime stage that does not
# re-declare it expands ${OMNIGRAPH_INTERNAL_SCHEMA} to the EMPTY STRING —
# and the build still succeeds, shipping an image that advertises no
# format. Downstream that reads as "image predates the label", which
# ol-infrastructure treats as non-fatal and skips, so the gate this whole
# mechanism exists to install would be off with nothing failing anywhere.
# Both lines that actually emit the label are therefore checked for.
#
# Scoped to the `runtime` stage, not the whole file: an ARG declared in any
# OTHER stage does nothing for the image that ships, so a file-wide grep
# would go green on exactly the arrangement it exists to reject.
for dockerfile in docker/omnigraph-server.Dockerfile docker/witan.Dockerfile; do
runtime=$(awk '/^FROM .* AS runtime$/{f=1;next} /^FROM /{f=0} f' "$dockerfile")
if [[ -z "$runtime" ]]; then
echo "$dockerfile has no 'FROM ... AS runtime' stage, so there is no" >&2
echo "stage to check the internal-schema label against." >&2
exit 1
fi
if ! grep -q '^ARG OMNIGRAPH_INTERNAL_SCHEMA$' <<<"$runtime"; then
echo "$dockerfile declares OMNIGRAPH_INTERNAL_SCHEMA but its runtime" >&2
echo "stage never re-declares it, so the label would build EMPTY." >&2
echo "(An ARG in any other stage does not carry into runtime.)" >&2
echo "Add a bare 'ARG OMNIGRAPH_INTERNAL_SCHEMA' before the LABEL." >&2
exit 1
fi
if ! grep -q 'edu.mit.ol.omnigraph.internal-schema="\${OMNIGRAPH_INTERNAL_SCHEMA}"' <<<"$runtime"; then
echo "$dockerfile's runtime stage does not emit" >&2
echo "edu.mit.ol.omnigraph.internal-schema" >&2
echo "from \${OMNIGRAPH_INTERNAL_SCHEMA}. ol-infrastructure reads that" >&2
echo "label to refuse a preview whose image reads the wrong format;" >&2
echo "without it the check silently degrades to skipped." >&2
exit 1
fi
done
if [[ "$installer_schema" != "$server_schema" || "$installer_schema" != "$mcp_schema" ]]; then
echo "omnigraph internal-schema declarations have drifted — the images" >&2
echo "would advertise a storage format this repo does not declare:" >&2
echo " packages/witan-core/witan_core/omnigraph_install.py: $installer_schema" >&2
echo " docker/omnigraph-server.Dockerfile: $server_schema" >&2
echo " docker/witan.Dockerfile: $mcp_schema" >&2
exit 1
fi
echo "omnigraph pins agree: $installer (tag $installer_tag, sha ${installer_sha:0:12}…, format $installer_schema)"
# The data tier's CLI may be built from patched source instead of taken
# from the tarball, so it has no digest for the checks above to compare.
# This holds that build's inputs to the release pin instead.
uv run --package witan-core --extra cli python bin/check_omnigraph_cli_patch.py
# Fail if the pinned omnigraph binary reads a storage format this repo does not
# declare — i.e. if a version bump is secretly a rebuild-every-graph event.
#
# Needs the pinned binary on PATH (`witan setup`, or the workflow's install
# step). See bin/check_omnigraph_format.py for why this compares the binary
# against a declaration rather than diffing two release pins.
#
# `--extra cli` is load-bearing: cyclopts (and rich, which the installer imports
# lazily) live in witan-core's `cli` extra, not its base dependencies. Without
# it this works only in a shared workspace .venv that some other package's sync
# happened to leave cyclopts in — which is exactly how it passed locally and
# failed in CI the first time.
check-omnigraph-format *args:
uv run --package witan-core --extra cli python bin/check_omnigraph_format.py {{ args }}
# Runs in CI on every PR, so drift is caught however the bump was made — which
# matters, because the previous drift happened by NOT using the tool: three of
# five packages had a `[tool.bumpversion].current_version` stranded several
# releases behind the real version, so `bump-my-version` searched for a string
# that no longer existed and silently did nothing.
# Fail if a shipped skill names a Claude-only tool (Agent tool, AskUserQuestion,
# built-in /review, subagents, ...) without a fallback in the same passage.
# Also runs as the `skill-portability` prek hook.
check-skills:
./bin/check_skill_portability.py
# Fail if any package's version, bumpversion config, and CHANGELOG disagree.
check-versions *args:
uv run --package witan-core --extra cli python bin/check_versions.py {{ args }}
# Fail if a server imports a witan_core symbol its declared floor predates.
#
# NEEDS NETWORK: it asks PyPI which witan-core versions exist, then installs the
# lowest one the floor admits into a throwaway venv. That is the point — the
# workspace resolves witan-core BY PATH, so any check that stays inside it
# proves nothing. See bin/check_core_floor.py for why the pin has to be `==`
# and why an unpublished floor is a pass, not a failure.
#
# Slower than the other checks (two wheel builds, two venvs, two installs), so
# it is its own CI job rather than a step inside another.
check-core-floor *args:
uv run --package witan-core --extra cli --with packaging python bin/check_core_floor.py {{ args }}
# FRAGMENTS ALL ALONG, ONE CHANGELOG ENTRY AT RELEASE TIME. Every package has
# a changelog.d/ (scriv, https://scriv.readthedocs.io/): a change gets its own
# fragment file there, named with a timestamp/author/branch so two branches
# never touch the same line — that per-file-per-change shape is the fix for
# the version/changelog drift `check-versions` exists to catch, which came
# from parallel edits to one growing CHANGELOG.md. `just changelog <package>`
# creates one; `just bump` folds every pending fragment into CHANGELOG.md
# under the version it just set and deletes them, so the two cannot drift —
# there is no longer a step where a human has to remember to write the entry
# at all, only to have written *a* fragment before release.
#
# Publishing is triggered by a push to main touching the package's own
# pyproject.toml (.github/workflows/publish-*.yml), so `bump` IS the release.
# It deliberately does not commit, tag, or push: what to say in the commit is
# a judgement call, and a recipe that pushed on your behalf would be one typo
# away from an unintended PyPI release.
#
# bump-my-version and scriv come via uvx rather than dev dependencies — needed
# only at release time, so pinning them into the workspace would put them in
# every contributor's environment for no benefit. Both ARE version-pinned:
# this recipe parses `show-bump`'s human-readable output, so an unpinned
# `uvx bump-my-version` could resolve a release that reformats it and break
# version calculation during a release; scriv's output becomes CHANGELOG.md
# text verbatim, so an unpinned `uvx scriv` could silently change that
# formatting out from under every package at once.
# Start a changelog fragment for a package, e.g. `just changelog witan-core`.
# Does not open the created file — scriv only does that when `git config
# scriv.create.edit true` is set, and defaulting it on here would try to
# launch $EDITOR in a non-interactive/agent session. Edit the printed path.
changelog package:
#!/usr/bin/env bash
set -euo pipefail
SCRIV_TOOL="scriv@1.8.0" # keep in step with AGENTS.md
case "{{ package }}" in
witan-core) dir=packages/witan-core ;;
witan-council) dir=mcp/servers/witan ;;
witan-code) dir=mcp/servers/witan-code ;;
agent-config-kit) dir=packages/agent-config-kit ;;
ol-agent-kit) dir=packages/agent-kit ;;
*) echo "unknown package '{{ package }}' — one of: witan-core witan-council witan-code agent-config-kit ol-agent-kit" >&2; exit 1 ;;
esac
(cd "$dir" && uvx "$SCRIV_TOOL" create)
# Release a package, e.g. `just bump witan-core minor`. Collects pending
# changelog fragments into CHANGELOG.md under the new version.
bump package part:
#!/usr/bin/env bash
set -euo pipefail
BUMP_TOOL="bump-my-version@1.5.1" # keep in step with AGENTS.md
SCRIV_TOOL="scriv@1.8.0" # keep in step with AGENTS.md
case "{{ package }}" in
witan-core) dir=packages/witan-core ;;
witan-council) dir=mcp/servers/witan ;;
witan-code) dir=mcp/servers/witan-code ;;
agent-config-kit) dir=packages/agent-config-kit ;;
ol-agent-kit) dir=packages/agent-kit ;;
*) echo "unknown package '{{ package }}' — one of: witan-core witan-council witan-code agent-config-kit ol-agent-kit" >&2; exit 1 ;;
esac
case "{{ part }}" in
major|minor|patch) ;;
*) echo "part must be major, minor or patch (got '{{ part }}')" >&2; exit 1 ;;
esac
# Everything must already agree before touching anything: bumping on top of
# existing drift would bury the drift in a release rather than fix it.
just check-versions
# `find` rather than `ls`/`grep`: an empty changelog.d/ (nothing but the
# README scriv itself skips) must not look like there's a fragment.
# Matched to scriv's own selection in _files_to_combine() — *.md/*.rst
# only, minus anything matching skip_fragments (README.*) — so a stray
# .DS_Store or editor backup can't pass this gate and then make the real
# `scriv collect` below exit 2 with "No changelog fragments to collect".
frags=$(find "$dir/changelog.d" -maxdepth 1 -type f \( -name '*.md' -o -name '*.rst' \) ! -name 'README.*' 2>/dev/null) || true
if [[ -z "$frags" ]]; then
echo "" >&2
echo "$dir/changelog.d has no fragments." >&2
echo "" >&2
echo "A release with no changelog fragment is one nobody can read." >&2
echo "Write one first:" >&2
echo " just changelog {{ package }}" >&2
exit 1
fi
# `show-bump --ascii` renders as `0.15.0 -- bump -+- minor - 0.16.0`, so the
# part name and its resulting version sit on one line. Asking the tool
# rather than computing it here keeps this honest if the versioning scheme
# ever grows a pre-release component — but it also means this recipe is
# coupled to a human-readable format, which is why the tool is PINNED
# (BUMP_TOOL, matching AGENTS.md): an unpinned `uvx` would resolve whatever
# is newest on the day and could reformat this output from under us, at
# release time, which is the worst moment to discover it.
#
# `|| true` and a separate emptiness check, NOT a bare assignment: under
# `set -euo pipefail` a `grep` that matches nothing exits 1, pipefail
# propagates it, and the command substitution takes the whole recipe down
# before the diagnostic below can run. With stderr suppressed as well, the
# releaser would see a bare non-zero exit and nothing else.
raw=$(cd "$dir" && uvx "$BUMP_TOOL" show-bump --ascii 2>&1) || true
new=$(printf '%s\n' "$raw" \
| grep -oE "\b{{ part }} - [0-9]+\.[0-9]+\.[0-9]+" \
| grep -oE "[0-9]+\.[0-9]+\.[0-9]+" | head -1) || true
if [[ -z "$new" ]]; then
echo "could not determine the {{ part }} version for {{ package }}." >&2
echo "\`$BUMP_TOOL show-bump\` said:" >&2
printf '%s\n' "$raw" >&2
exit 1
fi
# Collect before bumping, not after: if collect fails (a malformed
# fragment, a duplicate version, uvx unable to resolve the package), the
# version is still untouched and nothing here needs undoing. If bump then
# fails, the changelog is left documenting a version one commit ahead of
# [project].version — the same "entry written, not yet released" state
# `check-versions` already tolerates — recoverable by fixing whatever
# bump-my-version choked on and running `uvx "$BUMP_TOOL" bump ...`
# directly (changelog.d/ is empty by then, so `just bump` itself would
# refuse on the fragment gate above). The reverse order risks the
# opposite state instead — a bumped, published version with no changelog
# entry to show for it — which is exactly the drift this recipe exists to
# prevent.
(cd "$dir" && uvx "$SCRIV_TOOL" collect --version "$new")
(cd "$dir" && uvx "$BUMP_TOOL" bump "{{ part }}" --no-commit --no-tag)
just check-versions
echo ""
echo "{{ package }} bumped to ${new}. Commit together:"
echo " $dir/pyproject.toml"
echo " $dir/CHANGELOG.md"
echo " $dir/changelog.d/ (the collected fragments are now gone)"
# uv.lock records every workspace member's version, so it moves with the
# bump — `just check-versions` above runs uv and refreshes it. Left behind,
# the next `uv lock --check` (and CI) fails on a repo that looks untouched.
echo " uv.lock"
# ── Documentation site (witan-context, published on Read the Docs) ──────────
#
# Three kinds of page live under docs/: GENERATED (docs/reference/, built from
# the live tool objects, cyclopts command tree, and .pg schemas), MIRRORED
# (guides and ADRs copied from the package that owns them), and HANDWRITTEN
# (tutorials, overviews, explanation). `docs-gen` produces the first two;
# `docs-check` is the CI gate that stops them drifting from the code.
# ── The witan UI's contract with the server ──────────────────────────────────
#
# The UI's result types are hand-written (every bound tool is registered
# without an output_schema, so there is nothing to generate from). These
# fixtures are what keeps them honest: real results from real tool calls
# against a seeded store, which the frontend suite parses through the
# unwrapper. `ui-fixtures-check` is the CI gate that stops them drifting from
# the server, the same way `docs-check` gates generated pages.
# Re-record the UI fixtures from the live tools.
ui-fixtures:
./bin/gen_ui_fixtures.py
# Fail if any UI fixture is out of date. Runs in CI.
ui-fixtures-check:
./bin/gen_ui_fixtures.py --check
# Regenerate and re-mirror every non-handwritten docs page.
docs-gen:
./bin/gen_docs.py
# Fail if any generated or mirrored page is out of date. Runs in CI.
docs-check:
./bin/gen_docs.py --check
# Build the static site into site/, plus llms.txt and a markdown mirror for
# agents (bin/gen_llms.py) — see .readthedocs.yaml and docs.yml for why that
# step runs as plain `python3`, not `uv run`.
#
# Pinned to the same Zensical as .github/workflows/docs.yml and
# .readthedocs.yaml. Zensical is pre-1.0 and its output changes between patch
# releases, so an unpinned local preview would render a different site from the
# one CI checks and Read the Docs publishes — which is the exact drift the CI
# pin exists to prevent. Renovate bumps all three together.
docs-build:
uvx zensical@0.0.68 build
./bin/gen_llms.py
# Serve the docs locally with incremental rebuilds.
docs-serve:
uvx zensical@0.0.68 serve