Publishing a UI Bundle
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Antora 3.2.1 and the Antora Default UI (cloned at commit 0e38223a), and checked on
2026-10-26. A UI bundle is only useful once a playbook can reach it. This page covers the last step of the UI life
cycle: turning the output of npx gulp bundle into something a build can download, deciding how the URL is versioned,
and keeping Antora from serving a stale copy out of its cache.
The cache behaviour described below was reproduced with Antora 3.2.1 against a local HTTP server. The CI workflows were validated as YAML and their build steps were run locally, but the hosted services (GitLab CI, GitHub Actions, GitHub Releases) were not used, so the workflow files are not executed examples.
What publishing means for a UI bundle
Antora needs one thing from the UI: a ZIP archive, or an unzipped directory, reachable through ui.bundle.url. The build
of the UI project produces that archive, so publishing is just the question of where the file lives and how its address
changes (or does not change) between releases. There are three practical answers.
| Strategy | How the playbook points at it | Typical use |
|---|---|---|
Remote URL that changes with every release |
Reproducible builds; the site only changes its UI when someone edits the playbook. |
|
Remote URL that never changes |
|
Sites that always want the newest UI, as the Default UI documentation recommends for its own bundle. |
File committed next to the playbook |
|
Small projects and this site: no hosting, no network access, the UI is versioned with the content repository. |
Whichever you choose, the rules of the Antora loader are the same ones described in UI Bundle: a remote bundle must be a ZIP, it is cached by the signature of its URL, and a local path is read again on every run.
Producing the file
The Default UI project, and any UI forked from it, builds the archive with a single gulp task. The task lints the CSS
and JavaScript, builds the assets and zips them, so a CI job needs nothing more than npm ci followed by
npx gulp bundle. The details of the project are in
Building a Custom UI.
git clone https://gitlab.com/antora/antora-ui-default.git my-ui
cd my-ui
npm ci
npx gulp bundle
The last lines of a real run in a copy of the Default UI clone (Node.js 24.21.0) were these. The task ends by printing the Antora option that would load the bundle it just wrote:
[11:14:02] Starting 'bundle:pack'...
[11:14:02] Antora option: --ui-bundle-url=/private/.../ui-pub/ui-copy/build/ui-bundle.zip
[11:14:02] Finished 'bundle:pack' after 22 ms
[11:14:02] Finished 'bundle' after 1.61 s
The file is build/ui-bundle.zip. The name is fixed by the project (the gulp file sets the bundle name to ui), so a
release process that needs a version in the file name renames or uploads the file under another name; it is not
something the UI build does for you. bundle:pack writes the same archive from the already built files, without linting or rebuilding;
it is the last step of bundle.
Publishing from GitLab CI
The Default UI project itself is published this way, and it is the only publishing method the official documentation
shows. Its .gitlab-ci.yml defines a job named bundle-stable that runs gulp bundle on the default branch and
declares build/ui-bundle.zip as a job artifact. A job named bundle-dev does the same on every other branch, but
marks the artifact to expire after one day. GitLab then serves the file from the job artifacts URL of the project.
A trimmed version of that pipeline for your own UI looks like this. The Default UI pins an old Node.js image; use a recent one for a new project.
image: node:24
stages: [verify, deploy]
lint:
stage: verify
script:
- npm ci
- npx gulp lint
bundle-stable:
stage: deploy
only:
- main
script:
- npm ci
- npx gulp bundle
artifacts:
paths:
- build/ui-bundle.zip
This file is an adaptation of the Default UI pipeline and was not run on GitLab. The consumer then points the playbook at the latest artifact of the job on the default branch. The address has the same shape for every build, so it must be a snapshot. This is the playbook form the Antora documentation uses for the reference UI:
ui:
bundle:
url: https://gitlab.com/antora/antora-ui-default/-/jobs/artifacts/HEAD/raw/build/ui-bundle.zip?job=bundle-stable
snapshot: true
Source: Antora docs: UI Bundle URL
Replace antora/antora-ui-default with the path of your own project and bundle-stable with the name of your job.
The Antora documentation does not describe how long GitLab keeps job artifacts, so check the retention settings of your
GitLab instance before relying on an artifact URL for a long time.
Publishing from GitHub Actions (inference)
The official documentation does not describe publishing a custom bundle on GitHub. What follows is an inference from two documented facts: Antora accepts any HTTPS URL that returns a ZIP, and GitHub Releases serves release assets from stable URLs. A UI project hosted on GitHub can therefore publish its bundle as a release asset, which gives every release an address that never changes and a "latest" address that always moves.
GitHub documents two address forms for release assets: one that names the tag, and one that always resolves to the newest release.
| Form | Meaning |
|---|---|
|
The asset of one release. The content behind this address is fixed once the release is published. |
|
The asset of the newest release. Same address, different content over time. |
Source: GitHub docs: Linking to releases
The workflow below builds the bundle whenever a tag starting with v is pushed and attaches it to a release created
with the GitHub CLI, which is preinstalled on GitHub-hosted runners. It uses only the built-in GITHUB_TOKEN; no secret
has to be created. The action versions are the ones used by the official Antora GitHub Pages example and by the
publishing workflow of this repository.
name: release-ui
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Check out the UI project
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Build the bundle
run: npx gulp bundle
- name: Publish the bundle as a release asset
run: gh release create "$GITHUB_REF_NAME" build/ui-bundle.zip --title "$GITHUB_REF_NAME" --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Source: Antora docs: Publish to GitHub Pages (action versions), GitHub CLI manual: gh release create
The workflow file parses as valid YAML and its build steps (npm ci, npx gulp bundle) ran locally, but it was not
executed on GitHub, so the release step is untested. To cut a release you tag and push:
git tag v1.2.0
git push origin v1.2.0
The consuming playbook then picks one of the two address forms. A pinned version needs no snapshot because the URL
changes with each release; the "latest" form needs snapshot: true, for the reasons explained in the next section.
ui:
bundle:
url: https://github.com/<OWNER>/<REPO>/releases/download/v1.2.0/ui-bundle.zip
ui:
bundle:
url: https://github.com/<OWNER>/<REPO>/releases/latest/download/ui-bundle.zip
snapshot: true
Source: Antora docs: UI Bundle URL
Antora does not check a checksum of the bundle. If the integrity of the UI matters, publish a checksum next to the asset and verify it in your own build script before running Antora; that is outside what Antora offers.
Versioned URLs versus snapshot
Antora caches a remote bundle under a file name derived from its URL. As long as the URL stays the same, the cached
file is used and nothing is downloaded. The snapshot key opts out of that rule for a URL whose content changes, but
only when fetching is enabled, either with --fetch on the command line or with runtime.fetch: true in the
playbook.
The behaviour was reproduced with Antora 3.2.1. Two bundles that differ only in the footer text (VERSION-ONE and
VERSION-TWO) were served by a local HTTP server at one address, the cache was a fresh directory, and the footer of
the built site was read after every run.
| Scenario | snapshot |
--fetch |
Server serves | Site footer shows |
|---|---|---|---|---|
First build |
false |
no |
ONE |
ONE |
Server changed to TWO |
false |
no |
TWO |
ONE (cached) |
Same, fetch enabled |
false |
yes |
TWO |
ONE (cached) |
Same, snapshot enabled but no fetch |
true |
no |
TWO |
ONE (cached) |
Snapshot and fetch |
true |
yes |
TWO |
TWO |
Server changed back to ONE, snapshot and fetch |
true |
yes |
ONE |
ONE |
New file name ( |
false |
no |
TWO |
TWO (new URL, new cache entry) |
Three consequences follow, and they match the official page on the bundle URL.
-
A bundle at an unchanging URL needs both
snapshot: trueand fetch enabled. Either alone leaves the old copy in use, and Antora prints no warning. -
--fetchalso refreshes the git repositories of the content sources, so it is not free. If you only want the UI refreshed, you still pay for the content fetch, which is why a CI build that must always be current setsruntime.fetch: trueand a build run on a laptop usually does not. -
A URL that changes with every release needs neither key. It is also the only form that makes a build reproducible, because the same playbook always downloads the same bytes.
The cache location depends on the operating system: $XDG_CACHE_HOME/antora or $HOME/.cache/antora on Linux,
$HOME/Library/Caches/antora on macOS and $APPDATA/antora/Caches on Windows. It can be moved with --cache-dir, the
ANTORA_CACHE_DIR variable or runtime.cache_dir. Deleting the folder is also a valid way to force a download, and is
the first thing to try when a site still shows the old UI.
runtime:
fetch: true
cache_dir: ./.cache/antora
ui:
bundle:
url: https://github.com/<OWNER>/<REPO>/releases/latest/download/ui-bundle.zip
snapshot: true
Source: Antora docs: Cache Directory
A local path behaves differently. The same experiment with url: ./local.zip and snapshot: true showed the new
footer on the second run without --fetch, because Antora reads the file given by a filesystem path every time. The
snapshot key is only needed for a remote bundle.
Committing the bundle, as this site does
The playbook of this site does not download its UI at all. The custom UI is built in a separate project, and its
output, ui-bundle.zip, is committed at the root of this repository, next to antora-playbook.yml. The relevant part
of the playbook is short:
ui:
bundle:
url: ui-bundle.zip
snapshot: true
Source: Antora docs: UI Bundle URL
The path has no leading ./, so it is resolved against the current working directory. The nightly workflow runs
npx antora antora-playbook.yml from the repository root, where the file lives, so the bundle is found. A path that
starts with ./ would be resolved relative to the playbook file instead, which is more robust if the command is ever run
from another directory.
The committed archive is about 324 KiB with 76 entries and no ui.yml. Its history in this repository has two commits
touching it, which is the practical cost of the approach: every new bundle is a binary blob kept in the git history.
Because the path is local, snapshot: true has no effect here; the official documentation says the key matters only for
a remote bundle. It is harmless, and it would become useful the day the playbook switched to a remote URL.
| Committed bundle | Remote bundle (artifact or release) | |
|---|---|---|
Network at build time |
None for the UI |
One download, then cached |
Reproducibility |
The commit fixes the UI |
Fixed only with a versioned URL |
Updating the UI |
Commit a new file |
Publish a release, change the URL (or fetch a snapshot) |
Repository size |
Grows with every bundle |
Not affected |
Best for |
One site, one maintainer, a rarely changing UI |
A UI shared by several sites or teams |
A middle path is to keep the UI in its own repository, publish releases there, and let the documentation playbook name a versioned release URL. Updating the UI then becomes a one-line pull request in the documentation repository, and the history of the docs repository stays free of binary files.
The release and consumption flow
The sequence below puts the pieces together for the GitHub release variant. The maintainer tags the UI project, CI builds and attaches the bundle, and the next documentation build downloads it, caching it by URL.
Figure: from tag to rendered site, with the cache lookup that decides whether the network is used.
Pitfalls
-
A stale UI after a release is almost always the cache. Check that the URL really changed, or that both
snapshot: trueand fetch are in effect, before debugging the templates. Removing the cache folder proves the diagnosis. -
A remote bundle must be a ZIP archive. Check that the address serves the archive itself, without a login page in between, from the machine that runs the build.
-
A "latest" URL silently adopts a breaking UI change. Pin a version for production sites and use the moving address only for preview sites.
-
The supplemental files of the playbook are applied on top of whatever bundle is loaded, so a published bundle and a playbook that overrides files in it can drift apart. See Supplemental UI for how overrides and the
ui.ymlreplacement rule work. -
An unzipped directory is also accepted as
ui.bundle.url, which is the quickest way to try a change before publishing anything. The workflow is in Building a Custom UI.