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

url: https://<HOST>/<PATH>/v1.2.0/ui-bundle.zip

Reproducible builds; the site only changes its UI when someone edits the playbook.

Remote URL that never changes

url: https://<HOST>/<PATH>/latest/ui-bundle.zip plus snapshot: true

Sites that always want the newest UI, as the Default UI documentation recommends for its own bundle.

File committed next to the playbook

url: ui-bundle.zip (a path, not a URL)

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.

.gitlab-ci.yml
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

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

https://github.com/<OWNER>/<REPO>/releases/download/<TAG>/ui-bundle.zip

The asset of one release. The content behind this address is fixed once the release is published.

https://github.com/<OWNER>/<REPO>/releases/latest/download/ui-bundle.zip

The asset of the newest release. Same address, different content over time.

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.

.github/workflows/release-ui.yml
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 }}

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

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 (ui-bundle-2.zip)

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: true and fetch enabled. Either alone leaves the old copy in use, and Antora prints no warning.

  • --fetch also 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 sets runtime.fetch: true and 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

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

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.

sequenceDiagram actor Dev as UI maintainer participant Repo as UI repository participant CI as CI job participant Rel as Release storage participant Antora as Antora in docs CI participant Cache as Antora cache participant Site as Built site Dev->>Repo: push tag v1.2.0 Repo->>CI: start workflow CI->>CI: npm ci and npx gulp bundle CI->>Rel: attach build/ui-bundle.zip to the release Dev->>Antora: change ui.bundle.url to .../v1.2.0/ui-bundle.zip Antora->>Cache: look up URL signature alt not cached, or snapshot with fetch Antora->>Rel: download ui-bundle.zip Rel-->>Antora: ZIP archive Antora->>Cache: store file else cached Cache-->>Antora: cached ZIP end Antora->>Site: render pages with the bundle

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: true and 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.yml replacement 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.