Antora Deployment Strategies: Root vs. Docs
When documenting a single repository, there are two common ways to structure your Antora project: the Root Strategy and the Subfolder Strategy.
Both work, but they have different trade-offs for local development and CI/CD.
1. The Root Strategy (Recommended)
In this setup, the antora-playbook.yml lives at the repository root, even if the content is in docs/.
-
Structure:
text my-repo/ ├── antora-playbook.yml ←- Root ├── .github/workflows/docs.yml └── docs/ └── antora.yml -
Playbook Config: ```yaml content: sources:
-
url: . # Points to the root (current dir) start_path: docs # Tells Antora to look inside docs/
`
-
-
Pros:
-
CI Simplicity: In GitHub Actions, the checkout root is
.. The commandantora antora-playbook.ymljust works. No path overrides needed. -
Clarity: The playbook clearly defines the site for the entire repo.
-
Cons:
-
Adds one file to your root directory.
2. The Subfolder Strategy (Legacy)
In this setup, everything lives inside docs/, including the playbook.
-
Structure:
text my-repo/ └── docs/ ├── antora-playbook.yml ←- Inside docs/ └── antora.yml -
Playbook Config: ```yaml content: sources:
-
url: .. # MUST point to parent (git root) start_path: docs
`
-
-
Pros:
-
Keeps the repository root clean.
-
Cons:
-
CI Complexity: When running in CI, you are usually at the repo root. Running
antora docs/antora-playbook.ymlcauses relative path resolution specific to where the command is run vs where the file is. -
If
url: ., Antora thinks the git root isdocs/(Wrong). -
If
url: .., Antora tries to resolve the parent of the checkout (Restricted). -
Fix: You often have to force correct paths with
--url .CLI flags.
Recommendation
Use the Root Strategy.
It aligns with how CI systems view your repository (as a unit). The slight "clutter" of one file at the root is worth the stability in deployment pipelines.
Multi-Repository Handling
Your question is: Does this hold up when referencing other git repositories?
Yes, and it actually makes local development easier.
In a multi-repo site (like the main Dev Center), you have two distinct needs:
1. The Production/CI Need (Remote URLs)
In your main production playbook (e.g., dev-centr/docs/antora-playbook.yml), you should use Full GitHub URLs. This is the only way for GitHub Actions to fetch all the pieces.
content:
sources:
- url: https://github.com/dev-centr/msi-installer-generator.git
branches: HEAD
start_path: docs
2. The Local Development Need (Relative Paths)
When working locally, cloning is slow. You want to point to your sibling folders on your hard drive.
-
Root Strategy: Siblings are at
../sibling-repo. -
Subfolder Strategy: Siblings are at
../../sibling-repo.
By using the Root Strategy, your local dev references are cleaner and match the standard "folder per project" structure of most developers.
The "Best of Both Worlds" Solution
Do not manually toggle URLs in your main playbook. Instead, use a Local Dev Playbook Extension.
-
Create
antora-playbook-local.yml(and Git-ignore it). -
Define only the local overrides:
extends: ./antora-playbook.yml content: sources: - url: ../msi-generator branches: HEAD start_path: docs -
Run locally with:
antora antora-playbook-local.yml(orpnpm run build:localif that script points at it).
This keeps your Root Strategy intact for the repo’s specific CI, while allowing the global "hub" site to aggregate everything seamlessly.
Playbook file naming: why antora-playbook.yml and antora-playbook-local.yml
Use these names even though you might prefer a different scheme (e.g. playbook.yml for local and playbook-online.yml for production). The reason is ecosystem convention.
-
antora-playbook.yml= production/online playbook (remote URLs, used by CI). -
antora-playbook-local.yml= local-only playbook (sibling paths, git-ignored); extends the main playbook.
Why the main playbook must be "online":
In npm/pnpm, the script name build is the standard default. CI, READMEs, and tooling assume pnpm build (or npm run build) runs the main build—i.e. the one that produces the deployable site. So:
-
The
buildscript inpackage.jsonshould run the production playbook (antora-playbook.yml). That waypnpm builddoes what everyone expects. -
The
build:localscript runs the local playbook (antora-playbook-local.yml) when you want to use sibling repos.
Reasoning chain (what we tried):
-
First we tried naming by suffix:
playbook.yml(local) andplaybook-online.yml(online). Clean, but then the defaultpnpm buildwould run the local playbook—wrong for CI and for anyone running a plainpnpm build. -
Then we swapped so
build= online and addedbuild:online. That fixed CI, but we had to give up the nice short names and accept that "build" is a reserved default. -
Conclusion: the whole world expects
pnpm buildto be production. So the file that production uses should be the one that has the conventional name:antora-playbook.yml. The local override isantora-playbook-local.yml. Not our first choice, but it matches how the ecosystem works.
Private repos: GITHUB_TOKEN vs PAT (why you needed days of debugging)
Two different things use credentials. People often assume GITHUB_TOKEN does both. It does not.
-
Deploy to GitHub Pages (same repo): GITHUB_TOKEN is enough. The workflow runs in repo A, builds the site, and deploys to repo A’s Pages. No cross-repo access. No PAT needed.
-
Antora pulling content from other private repos: GITHUB_TOKEN is scoped only to the repository where the workflow runs. It does not have permission to clone other private repos in the org. When the playbook has
content.sourcespointing athttps://github.com/org/private-repo.git, Antora (via git) will try to clone that URL with no credentials and get 404 or "repository not found". So builds that aggregate docs from multiple private repos fail unless you add a token that can read those repos.
What to do when your playbook references private repos:
-
Create a Personal Access Token (PAT) that has read access to every private repo you list in
content.sources. Classic PAT:reposcope. Fine-grained PAT: give "Contents: Read" (or read-only) to each of those repos (or to the whole org if allowed). -
Store the PAT as a repo or org secret (e.g.
ANTORA_READ_TOKENorREPO_ACCESS_TOKEN). -
Before running Antora, make git use that token for
github.comso that when Antora clones the source URLs, the clone succeeds. Two common ways:Option A — git config (recommended): Configure the runner to use the token for any clone from your org:
- name: Configure git for private content sources run: | git config --global url."https://${{ secrets.ANTORA_READ_TOKEN }}@github.com/".insteadOf "https://github.com/" - name: Build docs run: pnpm dlx antora antora-playbook.ymlYour playbook keeps normal URLs (
https://github.com/org/private-repo.git). Git rewrites them to include the token when cloning.Option B — rewrite playbook URLs at build time: Generate a playbook (or env) where each source URL is
https://${{ secrets.ANTORA_READ_TOKEN }}@github.com/org/private-repo.git. Works but duplicates URLs and is easier to get wrong.
Summary:
| Action | GITHUB_TOKEN enough? | | Deploy site to this repo’s Pages | Yes | | Clone this repo (checkout) | Yes | | Clone other private repos (Antora content sources) | No — use a PAT and git config (or URL rewrite) |
So: "GITHUB_TOKEN does it for me" is true for deploy and for this repo. For private content sources in an org, you need a PAT and the git config step. That’s the distinction that causes the confusion and the long debugging sessions.
Package manager run commands
When writing or following docs that run a package binary (e.g. a dev server or a one-off tool), the behavior differs by package manager:
-
npm: A single entry point for both cases.
npx(ornpm exec) runs a binary from the current project’s dependencies if present, otherwise fetches and runs it. No separate "local" vs "download and run" command. -
pnpm: Two commands. Use
pnpm execto run a binary from already installed dependencies. Usepnpm dlxto download and run a package without adding it to the project (one-off execution). -
yarn (Berry 2+):
yarn dlxis the equivalent of "download and run"; for local binaries useyarn run <script>or the binary fromnode_modules/.bin. -
bun:
bunxbehaves likenpx(run local or fetch and run in one command).
When you see npx in documentation, use the equivalent for your manager: pnpm exec or pnpm dlx, yarn dlx (one-off) or yarn run, or bunx as appropriate.
GitHub Pages Configuration
Regardless of strategy, deployment is the same:
-
Build: Antora generates a static site (default:
build/siteorpublic/). -
Upload: Upload this folder to the
gh-pagesbranch. -
Configure: Set GitHub Pages to deploy from
gh-pages/root.
If the domain resolves but GitHub says "no site exists"
-
Publishing source: When the workflow pushes to
gh-pages(e.g. withpeaceiris/actions-gh-pages), the repo must use Deploy from a branch, not "GitHub Actions". In the repo Settings → Pages, set Source to Deploy from a branch, Branch togh-pages, folder/(root). -
Custom domain: In Settings → Pages, under "Custom domain", enter the full domain (e.g.
docs.devcentr.org) and save. GitHub does not read the custom domain from aCNAMEfile alone; you must set it in the UI (or API). -
CNAME in output: For branch-based deployment, put a
CNAMEfile at the root of the built site so thegh-pagesbranch contains it. In Antora, add the file to your supplemental UI and list it insupplemental-ui/ui.ymlunderstatic_filesso it is published to the site root.
Auto-rebuild when content sources or extensions update
Aggregated Antora sites should rebuild when a content source or shared extension/UI changes — without polling.
Use GitHub repository_dispatch on the site repo, fired with the gh CLI (agent-friendly) or a tiny optional workflow in the source repo.
This hub listens for:
-
docs-source-updated— a playbook content source pushed new docs -
extension-updated— UI bundle / extension / theme change
1. Subscriber site (the Antora playbook repo)
Add repository_dispatch to the deploy workflow:
# In .github/workflows/deploy.yml
on:
push:
branches: [main]
workflow_dispatch:
repository_dispatch:
types: [docs-source-updated, extension-updated]
Deploy jobs must allow repository_dispatch (not only push to main).
No polling; the site workflow runs when something pokes it.
2. Fire a rebuild with gh (preferred)
From any machine where gh is authenticated to the org (human or agent), after pushing content:
gh api repos/antora-supplemental/docs/dispatches \
-f event_type=docs-source-updated \
-f client_payload[source]=antora-supplemental/dprint-plugin-asciidoc \
-f client_payload[ref]=refs/heads/main \
-f client_payload[sha]=$(git rev-parse HEAD)
For a theme/extension release:
gh api repos/antora-supplemental/docs/dispatches \
-f event_type=extension-updated \
-f client_payload[source]=antora-supplemental/valentus-theme \
-f client_payload[ref]=${{ github.ref }}
gh uses your existing auth; no extra webhook receiver, no subscriber-ID registry.
Permissions: the token needs permission to trigger workflows on the subscriber repo (typically actions: write on that repo, or a classic PAT with repo).
3. Optional: auto-notify on push (minimal workflow)
If you want push → site rebuild without a manual gh step, copy the template from
antora-workflow-templates
(.github/workflows/notify-docs-hub.yml) into the content source repo.
It is one job: call repository_dispatch on the hub.
Store org/repo secret DOCS_DISPATCH_TOKEN (fine-grained PAT or GitHub App) that can dispatch into antora-supplemental/docs.
4. Event types
| Event type | When to send |
|---|---|
| docs-source-updated | Content repo docs paths changed |
| extension-updated | Theme, UI bundle, or playbook extension changed |
Summary
-
Subscriber: Listen for
repository_dispatchtypes; full Antora generate on each poke (v1 — correct, not incremental). -
Notify: Prefer
gh api …/dispatches(fast, agent-friendly). Optional one-step workflow in sources for hands-free push notify. -
Not required: Custom webhook HTTP endpoints, subscriber UUID registries, or polling remotes.