Merge pull request #24 from logos-blockchain/doctests

add doctests
This commit is contained in:
Iuri Matias 2026-06-15 16:34:09 -04:00 committed by GitHub
commit 2adacd3df5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 601 additions and 0 deletions

235
.github/workflows/doctests.yml vendored Normal file
View File

@ -0,0 +1,235 @@
name: execution-zone-module Doc-Tests
# Runs the executable execution-zone-module doc-test
# (doctests/logos-execution-zone-runtime.test.yaml) end-to-end via the shared
# doctest CLI: builds logoscore and lgpm, packages and installs THIS commit of
# the execution-zone module as an .lgx, starts the daemon, loads the module,
# calls its methods, and asserts on the output.
#
# ──────────────────────────────────────────────────────────────────────────────
# One-time setup required for the clickable report links to work:
#
# 1. Repo Settings → Pages → "Build and deployment" → Source: "Deploy from a
# branch", Branch: `gh-pages` / `(root)`. (The publish-report job creates
# the gh-pages branch on its first run.)
# 2. Nothing else — GITHUB_TOKEN already has the permissions granted below.
#
# Each run publishes the two-column HTML report to:
# https://<owner>.github.io/<repo>/pr-<N>/<os>/ (pull requests)
# https://<owner>.github.io/<repo>/main/<os>/ (pushes to main/master)
# and (for PRs) posts/updates a comment with the links.
#
# Note: pull requests opened from forks get a read-only GITHUB_TOKEN, so the
# Pages push and PR comment are skipped for them — the downloadable artifact is
# still produced. PRs from branches in this repo get the full clickable links.
# ──────────────────────────────────────────────────────────────────────────────
on:
pull_request:
branches: [master, main]
push:
branches: [master, main]
concurrency:
group: doctests-${{ github.ref }}
cancel-in-progress: true
jobs:
doctests:
name: execution-zone-module doc-tests (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
# Resolve the commit under test. For pull requests this is the PR's head
# commit (not the synthetic merge commit); for pushes it's the pushed
# commit. Passed to --release-for below so the doc-test packages THIS commit
# of logos-execution-zone-module instead of the latest published flake.
#
# Fork PRs are the exception: their head commit lives in the fork, not in
# logos-blockchain/logos-execution-zone-module, so nix could not fetch
# `github:logos-blockchain/logos-execution-zone-module/<sha>`. We blank the
# SHA for forks (--release-for repo= → pins that repo to latest), so the
# doc-test still runs for fork PRs, just against master.
- name: Resolve commit under test
id: commit
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ] && \
[ "${{ github.event.pull_request.head.repo.fork }}" = "true" ]; then
echo "sha=" >> "$GITHUB_OUTPUT"
echo "Fork PR detected — doc-test will run against latest master."
else
echo "sha=${{ github.event.pull_request.head.sha || github.sha }}" >> "$GITHUB_OUTPUT"
fi
# The runner is the shared `doctest` CLI, invoked directly via its flake
# (github:logos-co/logos-doctest). The flake bundles Python + PyYAML
# (+ rich), so no pip install step is needed.
#
# --release-for pins the {release} placeholder for logos-execution-zone-module
# to the commit under test, so
# `github:logos-blockchain/logos-execution-zone-module{release}` in the spec
# becomes `.../<sha>` — the doc-test packages and exercises this PR/push
# rather than master. Every other repo URL still resolves to latest.
- name: Run execution-zone-module runtime doc-test
run: |
# --continue-on-fail so the run walks every step and the published
# report is complete. The job still fails (non-zero exit) if any step
# failed; this only changes whether we stop early.
nix run github:logos-co/logos-doctest -- run \
doctests/logos-execution-zone-runtime.test.yaml \
--verbose \
--continue-on-fail \
--release-for logos-execution-zone-module=${{ steps.commit.outputs.sha }} \
--report "${{ runner.temp }}/lez-doctest-report.html"
- name: Stage report for upload
if: always()
shell: bash
run: |
mkdir -p report-out
# Name it index.html so the published directory URL renders directly.
if [ -f "${{ runner.temp }}/lez-doctest-report.html" ]; then
cp "${{ runner.temp }}/lez-doctest-report.html" report-out/index.html
else
echo "<h1>No report produced</h1>" > report-out/index.html
fi
- name: Upload execution-zone-module execution report
if: always()
uses: actions/upload-artifact@v4
with:
name: lez-doctest-report-${{ matrix.os }}
path: report-out/index.html
if-no-files-found: warn
- name: Verify markdown generation
run: |
nix run github:logos-co/logos-doctest -- generate \
doctests/logos-execution-zone-runtime.test.yaml \
--release-for logos-execution-zone-module=${{ steps.commit.outputs.sha }} \
-o "/tmp/logos-execution-zone-runtime.md"
test -s "/tmp/logos-execution-zone-runtime.md"
echo "Generated markdown successfully"
publish-report:
name: Publish report to GitHub Pages
needs: doctests
# Run even when tests fail — a failing run is exactly when you want to open
# the report. Skip on forks, where GITHUB_TOKEN can't push or comment.
if: ${{ always() && github.event.pull_request.head.repo.fork != true }}
runs-on: ubuntu-latest
permissions:
contents: write # push to the gh-pages branch
pull-requests: write # post/update the PR comment
# Serialize Pages pushes so two refs can't race on the gh-pages branch.
concurrency:
group: gh-pages-publish
cancel-in-progress: false
steps:
- name: Download all reports
uses: actions/download-artifact@v4
with:
path: artifacts
# No `name:` → downloads every artifact into artifacts/<name>/...
- name: Arrange site directory
id: arrange
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="pr-${{ github.event.pull_request.number }}"
else
BASE="main"
fi
echo "base=$BASE" >> "$GITHUB_OUTPUT"
mkdir -p "site/$BASE"
found=""
for os in ubuntu-latest macos-latest; do
src="artifacts/lez-doctest-report-$os/index.html"
if [ -f "$src" ]; then
mkdir -p "site/$BASE/$os"
cp "$src" "site/$BASE/$os/index.html"
found="$found $os"
fi
done
echo "found=$found" >> "$GITHUB_OUTPUT"
# Landing page for this ref linking to each OS report.
{
echo "<!doctype html><meta charset=utf-8>"
echo "<title>execution-zone-module doc-test reports — $BASE</title>"
echo "<style>body{font:16px system-ui;margin:40px;max-width:640px}a{color:#2563eb}</style>"
echo "<h1>execution-zone-module doc-test reports</h1>"
echo "<p><strong>$BASE</strong> · commit <code>${GITHUB_SHA::7}</code></p><ul>"
for os in ubuntu-latest macos-latest; do
if [ -d "site/$BASE/$os" ]; then
echo "<li><a href=\"./$os/\">$os</a></li>"
fi
done
echo "</ul>"
} > "site/$BASE/index.html"
- name: Deploy to gh-pages
if: steps.arrange.outputs.found != ''
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./site
keep_files: true # don't wipe other PRs' directories
commit_message: "Publish execution-zone-module doc-test report for ${{ steps.arrange.outputs.base }} (${{ github.sha }})"
- name: Comment on PR with report links
if: ${{ github.event_name == 'pull_request' && steps.arrange.outputs.found != '' }}
uses: actions/github-script@v7
with:
script: |
const base = "${{ steps.arrange.outputs.base }}";
const owner = context.repo.owner;
const repo = context.repo.repo;
const root = `https://${owner}.github.io/${repo}/${base}`;
const oses = "${{ steps.arrange.outputs.found }}".trim().split(/\s+/).filter(Boolean);
const links = oses.map(os => `- [\`${os}\` report](${root}/${os}/)`).join("\n");
const marker = "<!-- lez-doctest-report-links -->";
const body =
`${marker}\n` +
`### 📊 execution-zone-module doc-test report\n\n` +
`This commit of the execution-zone module, packaged as an \`.lgx\` and run ` +
`through a logoscore daemon — rendered alongside the commands actually ` +
`run and their output (updated each run, commit \`${context.sha.slice(0,7)}\`):\n\n` +
`${links}\n\n` +
`_Pages can take a minute to update after the run finishes._`;
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: context.issue.number, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: context.issue.number, body });
}

7
.gitignore vendored
View File

@ -10,3 +10,10 @@ result
# Node
resources/
# Doc-test output: generated artifacts are disposable, but keep the rendered
# .md that the docs embed. (report.html is published by CI to GitHub Pages, not
# committed.)
doctests/outputs/*
!doctests/outputs/*.md
!doctests/outputs/images/

View File

@ -0,0 +1,279 @@
name: "Running This Execution-Zone Module Against logoscore"
output: logos-execution-zone-runtime.md
release: ""
intro: |
`logos-execution-zone` is a Logos `core` module that wraps the
[Logos execution-zone wallet library](https://github.com/logos-blockchain/lssa)
(`wallet_ffi`) to provide wallet lifecycle, account management, balance and
block queries, transfers, pinata claiming, and account-id encoding. This
doc-test exercises **this** execution-zone-module commit end-to-end through the
headless `logoscore` runtime:
1. Build the `logoscore` CLI and the `lgpm` local package manager from their
published flakes. `logoscore` is the headless frontend for `logos-liblogos`,
so building it brings in the whole module-runtime stack (`logos_host`,
`liblogos_core`, the IPC layer).
2. Build **this** execution-zone module as an installable `.lgx` package
straight from its own flake's `#lgx` output, **pinned to the commit under
test** — so the module you run is built from exactly what is checked out
here, not the latest published release.
3. Install the `.lgx` into a `./modules` directory with `lgpm`.
4. Start `logoscore` in daemon mode (`-D`), load `logos_execution_zone`,
introspect it with `module-info`, and call its wallet-free methods —
verifying the module actually runs and round-trips real values through
`wallet_ffi`.
The methods exercised here — `name`/`version` and the base58 account-id codec
(`account_id_to_base58` / `account_id_from_base58`) — are the module's
deterministic, **offline** surface: they need neither an open wallet nor a live
sequencer, so a green run is reproducible in CI. The stateful operations
(creating accounts, transfers, sync, pinata claims) require a running
sequencer and network, and are covered by the module's unit tests (mocked
`wallet_ffi`) and integration tests (real `wallet_ffi`) instead.
Because the module is built from the commit under test and then loaded and
called through a real `logoscore` daemon, a green run is real evidence that
this change keeps the execution-zone module loadable and callable.
what_you_build: "This `logos_execution_zone` module, packaged as `.lgx`, installed with `lgpm`, and called through a `logoscore` daemon."
what_you_learn:
- How to build the `logoscore` runtime and the `lgpm` package manager from their flakes
- How a module's flake exposes a ready-to-install `.lgx` via its `#lgx` output
- How to install an `.lgx` into a modules directory with `lgpm`
- How to start the `logoscore` daemon, load a module, introspect it, and call its methods
- How to shut the daemon down and confirm it has exited
prerequisites:
- |
**Nix** with flakes enabled. Install from [nixos.org](https://nixos.org/download.html), then enable flakes:
```bash
mkdir -p ~/.config/nix
echo 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf
```
Verify: `nix flake --help >/dev/null 2>&1 && echo "Flakes enabled"`
- "**A Linux or macOS machine.**"
sections:
- title: "Build logoscore"
step: true
text: |
Build the `logoscore` CLI from its published flake. The result is symlinked to
`./logos/`. `logoscore` is the headless frontend for `logos-liblogos`, so this
one build brings in the whole module-runtime stack the daemon needs.
steps:
- title: "Build the CLI"
run: "nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos"
code_block: |
nix build 'github:logos-co/logos-logoscore-cli' --out-link ./logos
check_file: "logos/bin/logoscore"
post_text: |
The build produces `logos/bin/logoscore` plus bundled runtime libraries
and a `logos/modules/` directory containing the built-in
`capability_module` (required for the auth handshake when loading
modules).
- title: "Build the lgpm package manager"
step: true
text: |
`lgpm` installs `.lgx` packages into a modules directory and scans what is
installed. Build it from the `logos-package-manager` flake and link it as
`./lgpm`.
steps:
- title: "Build lgpm"
run: "nix build 'github:logos-co/logos-package-manager#cli' -o lgpm"
check_file: "lgpm/bin/lgpm"
post_text: "The executable is at `./lgpm/bin/lgpm`."
- title: "Build and install this execution-zone module"
step: true
text: |
Build **this** execution-zone module's `.lgx` straight from its flake's
`#lgx` output and install it into a local `./modules` directory with `lgpm`.
Every module built with
[`logos-module-builder`](https://github.com/logos-co/logos-module-builder)
exposes a ready-to-install `#lgx`.
> The `{release}` in the URL is what pins the build to a specific commit: the
> doc-test runner expands it to a concrete ref. Locally that is this
> checkout's `HEAD` (see `run.sh`); in CI it is the commit being tested. With
> no pin it falls back to the latest `master`.
steps:
- title: "Build the module's .lgx"
text: |
Build the `#lgx` output and link it as `./lez-lgx`. (This compiles the
module and the `wallet_ffi` library it depends on through Nix, so the
first build is slow.)
run: "nix build 'github:logos-blockchain/logos-execution-zone-module{release}#lgx' -o lez-lgx"
code_block: |
# From inside the clone this is simply: nix build '.#lgx'
nix build 'github:logos-blockchain/logos-execution-zone-module{release}#lgx' -o lez-lgx
post_text: "The `.lgx` package is now under `./lez-lgx/`:"
extra_run:
run: "ls lez-lgx/*.lgx"
- title: "Seed the modules directory with the bundled capability module"
text: |
`logos_execution_zone` is loaded through the host's capability layer, so
the modules directory also needs the `capability_module` that ships with
`logoscore`. Copy it across first.
run: |
mkdir -p modules
cp -RL ./logos/modules/. ./modules/
check_file: "modules/capability_module/manifest.json"
- title: "Install the .lgx with lgpm"
text: |
Install the freshly-built package into `./modules`. `logos_execution_zone`
is a `core` module, so it goes to `--modules-dir`. The package is unsigned
(a local dev build), so we pass `--allow-unsigned`.
run: "./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file lez-lgx/*.lgx"
expect_contains:
- "Installed to:"
- title: "Confirm the install"
text: "Scan the directory and confirm the module landed:"
run: "./lgpm/bin/lgpm --modules-dir ./modules list"
expect_contains:
- "logos_execution_zone"
check_file: "modules/logos_execution_zone/manifest.json"
- title: "Run the daemon and call the module"
step: true
text: |
Start `logoscore` in daemon mode pointed at `./modules`, then use the client
subcommands to load `logos_execution_zone`, introspect it, and call several
of its methods. Daemon output is captured in `logs.txt`.
steps:
- title: "Start the daemon"
text: |
Start logoscore in daemon mode in the background, capturing output to
`logs.txt`:
run: "sh -c './logos/bin/logoscore -D -m ./modules > logs.txt 2>&1 &'"
code_block: "logoscore -D -m ./modules > logs.txt &"
post_text: |
The `-D` flag starts the daemon. The client subcommands below connect to
this running process via the config written under `~/.logoscore/`.
- run: "sleep 3"
- title: "Inspect the startup log"
text: "Review the daemon's startup output:"
run: "cat logs.txt"
- title: "Check daemon status"
text: "Verify the daemon is running:"
run: "./logos/bin/logoscore status"
code_block: "logoscore status"
- title: "List discovered modules"
text: "`logos_execution_zone` should be visible in the scan directory:"
run: "./logos/bin/logoscore list-modules"
code_block: "logoscore list-modules"
expect_contains:
- "logos_execution_zone"
- title: "Load the module"
text: "Load `logos_execution_zone` into the running daemon:"
run: "./logos/bin/logoscore load-module logos_execution_zone"
code_block: "logoscore load-module logos_execution_zone"
expect_contains:
- "logos_execution_zone"
- title: "Confirm the module is loaded"
text: |
Re-run `status`; the module that was `not_loaded` before now reports
`loaded`:
run: "./logos/bin/logoscore status"
code_block: "logoscore status"
expect_contains:
- "logos_execution_zone"
- '"status":"loaded"'
- title: "Introspect the module with module-info"
text: |
`module-info` lists the methods the module exposes — the same methods you
can `call`:
run: "./logos/bin/logoscore module-info logos_execution_zone"
code_block: "logoscore module-info logos_execution_zone"
expect_contains:
- "logos_execution_zone"
- "account_id_to_base58"
- "account_id_from_base58"
- title: "Read the module name"
text: |
`name` returns the module's own identifier — the simplest possible
round-trip through the loaded plugin over liblogos' IPC:
run: "./logos/bin/logoscore call logos_execution_zone name"
code_block: "logoscore call logos_execution_zone name"
expect_contains:
- '"result":"logos_execution_zone"'
- title: "Read the module version"
text: |
`version` returns the module's semantic version (`1.0.0`), matching
`metadata.json`:
run: "./logos/bin/logoscore call logos_execution_zone version"
code_block: "logoscore call logos_execution_zone version"
expect_contains:
- '"result":"1.0.0"'
- title: "Encode an account id to base58"
text: |
`account_id_to_base58` takes a 32-byte account id as 64 hex characters
and returns its base58 form. This is a pure encoding helper in
`wallet_ffi` — no open wallet and no network required, so it runs
entirely offline:
run: "./logos/bin/logoscore call logos_execution_zone account_id_to_base58 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
code_block: "logoscore call logos_execution_zone account_id_to_base58 aaaaaaaa...aaaa # 64 hex chars"
expect_contains:
- '"result"'
- title: "Round-trip it back to hex"
text: |
`account_id_from_base58` is the inverse: feed it the base58 string we just
produced and it returns the original 64-hex account id. Encoding then
decoding the same id and recovering the input is a deterministic,
end-to-end proof that the codec — and the IPC path to this module — work:
run: |
ENC=$(./logos/bin/logoscore call logos_execution_zone account_id_to_base58 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa)
B58=$(printf '%s' "$ENC" | sed -n 's/.*"result":"\([^"]*\)".*/\1/p')
echo "encoded: $B58"
./logos/bin/logoscore call logos_execution_zone account_id_from_base58 "$B58"
code_block: |
# Encode, then decode the result back — the round-trip returns the input.
B58=$(logoscore call logos_execution_zone account_id_to_base58 aaaa...aaaa)
logoscore call logos_execution_zone account_id_from_base58 "$B58"
expect_contains:
- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- title: "Reject malformed base58"
text: |
Decoding obvious garbage fails cleanly: `account_id_from_base58` returns
an empty result rather than crashing the module or the daemon:
run: "./logos/bin/logoscore call logos_execution_zone account_id_from_base58 '!!!not-base58!!!'"
code_block: "logoscore call logos_execution_zone account_id_from_base58 '!!!not-base58!!!'"
expect_contains:
- '"result":""'
- title: "Stop the daemon"
text: "Shut the daemon down cleanly:"
run: "./logos/bin/logoscore stop"
code_block: "logoscore stop"
post_text: |
The daemon removes its state file and exits.
- run: "sleep 2"
- title: "Confirm the daemon has stopped"
text: |
With no daemon running, the client reports `not_running` and exits
non-zero, so we add `|| true` to let the doc-test assert on the output:
run: "./logos/bin/logoscore status || true"
code_block: "logoscore status"
expect_contains:
- '"status":"not_running"'

80
doctests/run.sh Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
#
# Execute the execution-zone-module doc-test end-to-end and regenerate its Markdown.
#
# There is one spec:
# logos-execution-zone-runtime.test.yaml — packages this module as an .lgx,
# installs it with lgpm, and drives it through a headless logoscore daemon,
# calling its wallet-free methods (name/version and the base58 account-id
# codec) and asserting on the output.
#
# The runner is the shared `doctest` CLI
# (https://github.com/logos-co/logos-doctest), invoked directly via its flake.
# The spec runs into ./outputs/ via --output-dir; `doctest generate` renders the
# .md; `doctest clean` then strips build artifacts, keeping only the .md.
#
# To run against a local logos-doctest checkout instead of the published flake,
# set DOCTEST, e.g.: DOCTEST="nix run path:../../logos-doctest --" ./run.sh
#
set -euo pipefail
# Run from this doctests/ directory regardless of where the script is invoked from.
cd "$(dirname "$0")"
# The doctest CLI. Override by exporting DOCTEST (space-separated command).
read -r -a DOCTEST <<< "${DOCTEST:-nix run github:logos-co/logos-doctest --}"
OUTPUT_DIR="./outputs"
# Build the doc-test against THIS repo's current commit rather than the latest
# published flake. The spec pins `github:logos-blockchain/logos-execution-zone-module{release}`
# to $COMMIT via --release-for, so it packages exactly what is checked out here.
# Override by exporting COMMIT (e.g. a tag), or set COMMIT="" to fall back to
# latest master.
#
# Note: nix fetches the commit from the GitHub remote, so $COMMIT must be pushed
# to logos-blockchain/logos-execution-zone-module. A local-only / uncommitted HEAD
# won't resolve; export COMMIT="" (or push first) in that case.
#
# This is a private repo, so nix must be able to authenticate to GitHub. If the
# build 404s on the commit even after pushing, add a token to ~/.config/nix/nix.conf:
# access-tokens = github.com=ghp_yourtoken
COMMIT="${COMMIT-$(git rev-parse HEAD)}"
RELEASE_FOR=()
if [ -n "${COMMIT}" ]; then
RELEASE_FOR=(--release-for "logos-execution-zone-module=${COMMIT}")
echo "==> Pinning logos-execution-zone-module to ${COMMIT}"
else
echo "==> COMMIT empty; building from latest logos-execution-zone-module master"
fi
echo "==> Clearing previous ${OUTPUT_DIR}/"
# A prior run copies module artifacts out of the read-only nix store, so the
# directories land read-only (r-x) too. `rm -rf` can't delete files inside a
# directory it can't write to, so restore write permission first.
if [ -e "${OUTPUT_DIR}" ]; then
chmod -R u+w "${OUTPUT_DIR}" 2>/dev/null || true
fi
rm -rf "${OUTPUT_DIR}"
mkdir -p "${OUTPUT_DIR}"
for spec in *.test.yaml; do
name="$(basename "${spec%.test.yaml}")"
echo "==> Running ${spec} into ${OUTPUT_DIR}/"
# ${RELEASE_FOR[@]+...} guards the expansion so an empty array doesn't trip
# `set -u` on older bash (e.g. macOS's stock 3.2).
"${DOCTEST[@]}" run "${spec}" \
--verbose \
--continue-on-fail \
${RELEASE_FOR[@]+"${RELEASE_FOR[@]}"} \
--output-dir "${OUTPUT_DIR}/"
echo "==> Generating ${OUTPUT_DIR}/${name}.md"
"${DOCTEST[@]}" generate "${spec}" \
${RELEASE_FOR[@]+"${RELEASE_FOR[@]}"} \
-o "${OUTPUT_DIR}/${name}.md"
done
echo "==> Cleaning build artifacts from ${OUTPUT_DIR}/ (keeps .md)"
"${DOCTEST[@]}" clean "${OUTPUT_DIR}" --verbose
echo "==> Done. Rendered docs are in ${OUTPUT_DIR}/"